Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 89 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ many concurrent pull requests.

## Features

- **Comprehensive metrics** – see the [Metric Reference](https://owner.github.io/pull-request-score/docs/metric-reference) for the full list.
- **Comprehensive metrics** – see the [Metric Reference](https://owner.github.io/pull-request-score/docs/metric-reference) for the full list.
- **Support for GitHub Enterprise** via the `--base-url` option.
- **CLI and library usage** for flexibility.
- **Label based filtering** so monorepo users can target a specific team or
Expand All @@ -36,12 +36,12 @@ many concurrent pull requests.
### Parsing ticket IDs

```ts
import { parseTicket, hasTicket } from 'pull-request-score'
import { parseTicket, hasTicket } from "pull-request-score";

parseTicket('BOSS-1252 fix bug')
parseTicket("BOSS-1252 fix bug");
// => { team: 'BOSS', number: 1252 }

hasTicket('no ticket here')
hasTicket("no ticket here");
// => false
```

Expand Down Expand Up @@ -159,44 +159,52 @@ import {
collectPullRequests,
calculateMetrics,
scoreMetrics,
} from 'pull-request-score'
} from "pull-request-score";

const prs = await collectPullRequests({
owner: 'my-org',
repo: 'my-repo',
baseUrl: 'https://github.mycompany.com/api/v3',
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 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)
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}`)
{ 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
Expand All @@ -218,31 +226,31 @@ After calculating metrics you can derive a single numeric score by
combining them with custom weights.

```ts
import { scoreMetrics } from 'pull-request-score'
import { scoreMetrics } from "pull-request-score";

const score = scoreMetrics(metrics, [
{ weight: 0.6, metric: 'mergeRate' },
{ weight: 0.4, metric: 'reviewCoverage' },
])
{ weight: 0.6, metric: "mergeRate" },
{ weight: 0.4, metric: "reviewCoverage" },
]);
```

Rules may also use custom functions to leverage any metric data:

```ts
const score = scoreMetrics(metrics, [
{ weight: 1, metric: 'mergeRate' },
{ weight: -0.1, fn: m => m.prBacklog },
])
{ 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:

```ts
const score = scoreMetrics(metrics, [
{ weight: 0.5, metric: 'mergeRate', normalize: v => v * 100 },
{ weight: 0.5, metric: 'reviewCoverage', normalize: v => v * 100 },
])
{ 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
Expand All @@ -251,28 +259,28 @@ To include comments in your score, combine `discussionCoverage` and

```ts
const score = scoreMetrics(metrics, [
{ weight: 0.5, metric: 'discussionCoverage', normalize: v => v * 100 },
{ weight: 0.5, metric: 'commentQuality', normalize: v => v * 100 },
])
{ 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:

```ts
const totalComments = (m: any) =>
Object.values(m.commentCounts).reduce((a, b) => a + b, 0)
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 },
])
{ 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:

```ts
import { scoreMetrics, createRangeNormalizer } from 'pull-request-score'
import { scoreMetrics, createRangeNormalizer } from "pull-request-score";

const normalizePickupTime = createRangeNormalizer(
[
Expand All @@ -281,17 +289,17 @@ const normalizePickupTime = createRangeNormalizer(
{ max: 12, score: 60 },
],
40,
)
);

const score = scoreMetrics({ pickupTime: 5 }, [
{ weight: 1, metric: 'pickupTime', normalize: normalizePickupTime },
])
{ weight: 1, metric: "pickupTime", normalize: normalizePickupTime },
]);
```

You can reuse the normalizer alongside others for a combined score:

```ts
const metrics = { pickupTime: 2, mergeRate: 0.95 }
const metrics = { pickupTime: 2, mergeRate: 0.95 };

const normalizePickupTime = createRangeNormalizer(
[
Expand All @@ -300,14 +308,14 @@ const normalizePickupTime = createRangeNormalizer(
{ max: 12, score: 60 },
],
40,
)
);

const pct = (v: number) => Math.round(v * 100)
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 },
])
{ weight: 0.5, metric: "pickupTime", normalize: normalizePickupTime },
{ weight: 0.5, metric: "mergeRate", normalize: pct },
]);
// => 98
```

Expand All @@ -316,9 +324,27 @@ compare rounding strategies:

```ts
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) },
])
{ weight: 0.5, metric: "mergeRate", normalize: (v) => Math.floor(v * 100) },
{ weight: 0.5, metric: "mergeRate", normalize: (v) => Math.ceil(v * 100) },
]);
// => 95.5
```

## Playground

The `playground` directory contains runnable examples for experimenting with the
library. After cloning this repo, install dependencies and build the project:

```bash
pnpm install
pnpm build
```

Run the examples:

```bash
node playground/score-metrics.mjs
GH_TOKEN=YOUR_TOKEN node playground/fetch-metrics.mjs owner/repo
```

See [playground/README.md](playground/README.md) for more details.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"build": "tsc",
"lint": "eslint . --ext .ts",
"format": "prettier --write .",
"test": "pnpm exec jest --coverage",
"test": "pnpm build && pnpm exec jest --coverage",
"release": "semantic-release"
},
"engines": {
Expand Down Expand Up @@ -51,6 +51,7 @@
"@octokit/plugin-throttling": "^11.0.1",
"@octokit/request": "^10.0.2",
"better-sqlite3": "^11.10.0",
"bottleneck": "^2.19.5",
"commander": "^11.1.0",
"ms": "^2.1.3",
"pino": "^9.7.0"
Expand Down
39 changes: 39 additions & 0 deletions playground/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Playground

Example scripts for experimenting with `pull-request-score`.

## Setup

Install dependencies and build the project:

```bash
pnpm install
pnpm build
```

## Examples

### Fetch metrics from GitHub

Requires a GitHub token in `GH_TOKEN`.

```bash
GH_TOKEN=YOUR_TOKEN node playground/fetch-metrics.mjs owner/repo
```

Omitting `owner/repo` defaults to `octocat/Hello-World`.

For GitHub Enterprise, you can specify a custom API base URL:

```bash
GH_TOKEN=TOKEN node playground/fetch-metrics.mjs owner/repo --base-url https://ghe.example.com/api/v3
```

If your instance uses a self-signed certificate, add `--insecure` to bypass
certificate validation.

### Score some metrics

```bash
node playground/score-metrics.mjs
```
39 changes: 39 additions & 0 deletions playground/fetch-metrics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { collectPullRequests, calculateMetrics } from "../dist/index.js";
import { pathToFileURL } from "url";

export async function fetchMetrics(repoArg = "octocat/Hello-World", opts = {}) {
const [owner, repo] = repoArg.split("/");
const token = process.env.GH_TOKEN;
if (!token) {
throw new Error("Set GH_TOKEN environment variable to a GitHub token");
}
const prs = await collectPullRequests({
owner,
repo,
since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
auth: token,
baseUrl: opts.baseUrl,
rejectUnauthorized: opts.rejectUnauthorized,
});
return calculateMetrics(prs);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const args = process.argv.slice(2);
let repo = args[0];
let baseUrl;
let insecure = false;
for (let i = 1; i < args.length; i++) {
if (args[i] === "--base-url") baseUrl = args[++i];
else if (args[i] === "--insecure") insecure = true;
}
fetchMetrics(repo, {
baseUrl,
rejectUnauthorized: insecure ? false : undefined,
})
.then((m) => console.log(JSON.stringify(m, null, 2)))
.catch((err) => {
console.error(err);
process.exit(1);
});
}
14 changes: 14 additions & 0 deletions playground/score-metrics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { scoreMetrics } from "../dist/scoring.js";

const metrics = { mergeRate: 0.95, reviewCoverage: 0.8 };

const score = scoreMetrics(metrics, [
{ weight: 0.5, metric: "mergeRate", normalize: (v) => Math.round(v * 100) },
{
weight: 0.5,
metric: "reviewCoverage",
normalize: (v) => Math.round(v * 100),
},
]);

console.log("Score:", score);
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading