pull-request-score
v1.0.0
Published
[](https://www.npmjs.com/package/pull-request-score) [](https://github.com/owner/repo/actions/workflows/ci.yml) [![covera
Readme
pull-request-score
pull-request-score collects and aggregates pull request data from GitHub. It was
built to help teams understand how code moves through their repositories and to
highlight opportunities for process improvements. The project exposes both a CLI
for quick analysis as well as a library for building custom workflows.
Why does this exist?
Tracking cycle time, review responsiveness and CI reliability across many pull requests is tedious. This package automates the heavy lifting so you can focus on interpreting the numbers. It works against the public GitHub API and GitHub Enterprise installations and is designed to scale to large organizations with many concurrent pull requests.
Requirements
- Node.js 18 or newer
- pnpm package manager
Features
- Comprehensive metrics – see the Metric Reference for the full list.
- Support for GitHub Enterprise via the
--base-urloption. - CLI and library usage for flexibility.
- Label based filtering so monorepo users can target a specific team or category of work.
- Ticket ID extraction from PR titles like
BOSS-1252to capture team and ticket number. - Optional throttling helper for environments with strict API limits.
Parsing ticket IDs
import { parseTicket, hasTicket } from 'pull-request-score'
parseTicket('BOSS-1252 fix bug')
// => { team: 'BOSS', number: 1252 }
hasTicket('no ticket here')
// => falseDocumentation
Browse the website for usage guides and API details at https://owner.github.io/pull-request-score/.
Installation
pnpm add pull-request-scoreWhen you only need a quick report you can use npx:
npx gh-pr-metrics octocat/hello-world --since 30dUsing the CLI
npx gh-pr-metrics <owner/repo> --since 30d --token YOUR_TOKEN \
--base-url https://github.mycompany.com/api/v3 --progressOptions
--since <duration>– look back period. Values like30dor2ware supported. Defaults to90d.--token <token>– GitHub token or use theGH_TOKENenvironment variable.--base-url <url>– API root when running against GitHub Enterprise.--format <json|csv>– output format. Defaults to JSON.--output <path|stdout|stderr>– where to write metrics. Defaults to stdout.--progress– show fetching progress on stderr.--dry-run– print the options that would be used and exit.--include-labels <a,b>– only include pull requests that have any of the given labels.--exclude-labels <a,b>– skip pull requests that have any of the given labels.
Label filters make it easy for large enterprises to slice metrics per team when many groups share a monorepo. Omitting the filters aggregates over all pull requests.
Examples
Output metrics as CSV:
npx gh-pr-metrics myorg/app --format csv --token MY_TOKEN \
--output metrics.csvGenerate metrics for just the team-a labeled pull requests:
npx gh-pr-metrics myorg/app --since 7d --token MY_TOKEN \
--include-labels team-aExclude work in progress PRs:
npx gh-pr-metrics myorg/app --exclude-labels wip --token MY_TOKENLibrary Usage
All functionality exposed by the CLI is also available programmatically.
import {
collectPullRequests,
calculateMetrics,
calculateCycleTime,
calculateReviewMetrics,
calculateCiMetrics,
writeOutput,
} from "pull-request-score";
const prs = await collectPullRequests({
owner: "octocat",
repo: "hello-world",
since: new Date(Date.now() - 30 * 86_400_000).toISOString(),
auth: "YOUR_TOKEN",
includeLabels: ["team-a"],
});
const metrics = calculateMetrics(prs, {
outsizedThreshold: 1500,
staleDays: 60,
});
console.log(calculateCycleTime(prs[0]));
console.log(calculateReviewMetrics(prs[0]));
console.log(calculateCiMetrics(prs[0]));
writeOutput(metrics, { format: "json" });See docs/rate-limiter.md for details on pacing API requests and docs/write-output.md for controlling output formats. For custom metrics see the Plugin API docs.
Enterprise Usage
Enterprises can weight every available metric to derive an overall pull request health score. The example below connects to a GitHub Enterprise server, enables comment quality analysis and combines all metrics into a single number.
import {
collectPullRequests,
calculateMetrics,
scoreMetrics,
} from 'pull-request-score'
const prs = await collectPullRequests({
owner: 'my-org',
repo: 'my-repo',
baseUrl: 'https://github.mycompany.com/api/v3',
auth: process.env.GH_TOKEN,
since: new Date(Date.now() - 30 * 86_400_000).toISOString(),
})
const metrics = calculateMetrics(prs, { enableCommentQuality: true })
const pct = (v: number) => v * 100
const sum = (obj: Record<string, number>) =>
Object.values(obj).reduce((a, b) => a + b, 0)
const enterpriseScore = scoreMetrics(metrics, [
{ metric: 'cycleTime', weight: -0.05 },
{ metric: 'pickupTime', weight: -0.05 },
{ metric: 'mergeRate', weight: 0.05, normalize: pct },
{ metric: 'closedWithoutMergeRate', weight: -0.05, normalize: pct },
{ metric: 'reviewCoverage', weight: 0.05, normalize: pct },
{ metric: 'averageCommitsPerPr', weight: -0.05 },
{ metric: 'outsizedPrs', weight: -0.05, fn: m => m.outsizedPrs.length },
{ metric: 'buildSuccessRate', weight: 0.05, normalize: pct },
{ metric: 'averageCiDuration', weight: -0.05 },
{ metric: 'stalePrCount', weight: -0.05 },
{ metric: 'hotfixFrequency', weight: -0.05, normalize: pct },
{ metric: 'prBacklog', weight: -0.05 },
{ metric: 'prCountPerDeveloper', weight: 0.05, fn: m => Object.keys(m.prCountPerDeveloper).length },
{ metric: 'reviewCounts', weight: 0.05, fn: m => sum(m.reviewCounts) },
{ metric: 'commentCounts', weight: 0.05, fn: m => sum(m.commentCounts) },
{ metric: 'commenterCounts', weight: 0.05, fn: m => sum(m.commenterCounts) },
{ metric: 'discussionCoverage', weight: 0.05, normalize: pct },
{ metric: 'commentDensity', weight: 0.05, normalize: pct },
{ metric: 'commentQuality', weight: 0.05, normalize: pct },
])
console.log(`Enterprise score: ${enterpriseScore}`)Development
pnpm install
pnpm testContributions are welcome! The project is intentionally small and easy to extend for additional metrics.
See docs/metric-reference.md for definitions of all available metrics.
scoreMetrics
After calculating metrics you can derive a single numeric score by combining them with custom weights.
import { scoreMetrics } from 'pull-request-score'
const score = scoreMetrics(metrics, [
{ weight: 0.6, metric: 'mergeRate' },
{ weight: 0.4, metric: 'reviewCoverage' },
])Rules may also use custom functions to leverage any metric data:
const score = scoreMetrics(metrics, [
{ weight: 1, metric: 'mergeRate' },
{ weight: -0.1, fn: m => m.prBacklog },
])Metrics can be normalized before weighting using the normalize option. This
is useful for converting ratios into a 1–100 scale:
const score = scoreMetrics(metrics, [
{ weight: 0.5, metric: 'mergeRate', normalize: v => v * 100 },
{ weight: 0.5, metric: 'reviewCoverage', normalize: v => v * 100 },
])To include comments in your score, combine discussionCoverage and
commentQuality (enable with enableCommentQuality in
calculateMetrics):
const score = scoreMetrics(metrics, [
{ weight: 0.5, metric: 'discussionCoverage', normalize: v => v * 100 },
{ weight: 0.5, metric: 'commentQuality', normalize: v => v * 100 },
])To reward raw comment volume across all PRs you can supply a custom rule:
const totalComments = (m: any) =>
Object.values(m.commentCounts).reduce((a, b) => a + b, 0)
const score = scoreMetrics(metrics, [
{ weight: 0.7, fn: totalComments },
{ weight: 0.3, metric: 'commentQuality', normalize: v => v * 100 },
])More advanced transforms can convert ranges of values to discrete scores. The
createRangeNormalizer helper makes this easy:
import { scoreMetrics, createRangeNormalizer } from 'pull-request-score'
const normalizePickupTime = createRangeNormalizer(
[
{ max: 4, score: 100 },
{ max: 6, score: 80 },
{ max: 12, score: 60 },
],
40,
)
const score = scoreMetrics({ pickupTime: 5 }, [
{ weight: 1, metric: 'pickupTime', normalize: normalizePickupTime },
])You can reuse the normalizer alongside others for a combined score:
const metrics = { pickupTime: 2, mergeRate: 0.95 }
const normalizePickupTime = createRangeNormalizer(
[
{ max: 4, score: 100 },
{ max: 6, score: 80 },
{ max: 12, score: 60 },
],
40,
)
const pct = (v: number) => Math.round(v * 100)
const score = scoreMetrics(metrics, [
{ weight: 0.5, metric: 'pickupTime', normalize: normalizePickupTime },
{ weight: 0.5, metric: 'mergeRate', normalize: pct },
])
// => 98Multiple rules can even target the same metric, for example to compare rounding strategies:
scoreMetrics({ mergeRate: 0.955 }, [
{ weight: 0.5, metric: 'mergeRate', normalize: v => Math.floor(v * 100) },
{ weight: 0.5, metric: 'mergeRate', normalize: v => Math.ceil(v * 100) },
])
// => 95.5