From 61db74807945d0c5f57d539d315b56d4feb49858 Mon Sep 17 00:00:00 2001 From: Jason Humphrey Date: Fri, 8 Aug 2025 12:11:18 -0500 Subject: [PATCH 1/5] build before running tests --- README.md | 152 +++++++++++++++---------- package.json | 3 +- playground/README.md | 39 +++++++ playground/fetch-metrics.mjs | 39 +++++++ playground/score-metrics.mjs | 14 +++ pnpm-lock.yaml | 3 + src/api/githubGraphql.ts | 10 ++ src/collectors/pullRequests.ts | 3 + src/index.ts | 2 + src/plugins/builtins.ts | 11 ++ src/plugins/registry.ts | 13 --- test/fixtures/test-cert.pem | 19 ++++ test/fixtures/test-key.pem | 28 +++++ test/playground-fetch-insecure.test.ts | 76 +++++++++++++ test/playground-fetch.test.ts | 14 +++ test/scripts/run-fetch-example.mjs | 26 +++++ 16 files changed, 375 insertions(+), 77 deletions(-) create mode 100644 playground/README.md create mode 100644 playground/fetch-metrics.mjs create mode 100644 playground/score-metrics.mjs create mode 100644 src/plugins/builtins.ts create mode 100644 test/fixtures/test-cert.pem create mode 100644 test/fixtures/test-key.pem create mode 100644 test/playground-fetch-insecure.test.ts create mode 100644 test/playground-fetch.test.ts create mode 100644 test/scripts/run-fetch-example.mjs diff --git a/README.md b/README.md index 991f322..b0d9797 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` @@ -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) => - 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 @@ -218,21 +226,21 @@ 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 @@ -240,9 +248,9 @@ 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 @@ -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( [ @@ -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( [ @@ -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 ``` @@ -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. diff --git a/package.json b/package.json index 8dd4aa2..69980b0 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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" diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..d377359 --- /dev/null +++ b/playground/README.md @@ -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 +``` diff --git a/playground/fetch-metrics.mjs b/playground/fetch-metrics.mjs new file mode 100644 index 0000000..1d78959 --- /dev/null +++ b/playground/fetch-metrics.mjs @@ -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); + }); +} diff --git a/playground/score-metrics.mjs b/playground/score-metrics.mjs new file mode 100644 index 0000000..01d2b84 --- /dev/null +++ b/playground/score-metrics.mjs @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a70ca7..5d0b75b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: better-sqlite3: specifier: ^11.10.0 version: 11.10.0 + bottleneck: + specifier: ^2.19.5 + version: 2.19.5 commander: specifier: ^11.1.0 version: 11.1.0 diff --git a/src/api/githubGraphql.ts b/src/api/githubGraphql.ts index 31f4fa0..0df026b 100644 --- a/src/api/githubGraphql.ts +++ b/src/api/githubGraphql.ts @@ -18,6 +18,11 @@ export interface GraphQLClientOptions { * The rate limit is expressed as requests per minute. */ throttle?: { requestsPerMinute: number }; + /** + * When `false`, allow self-signed or otherwise invalid HTTPS certificates. + * Defaults to `true`. + */ + rejectUnauthorized?: boolean; } /** @@ -40,6 +45,11 @@ export const makeGraphQLClient = ( const OctokitWithThrottle = Octokit.plugin(throttling); + if (opts.rejectUnauthorized === false) { + // Allow self-signed certificates by disabling TLS verification globally + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; + } + const octokit = new OctokitWithThrottle({ baseUrl: opts.baseUrl, throttle: { diff --git a/src/collectors/pullRequests.ts b/src/collectors/pullRequests.ts index acda796..8bdf949 100644 --- a/src/collectors/pullRequests.ts +++ b/src/collectors/pullRequests.ts @@ -38,6 +38,8 @@ export interface CollectPullRequestsParams { since: string; auth: string; baseUrl?: string; + /** Allow connections to servers with self-signed certificates */ + rejectUnauthorized?: boolean; onProgress?: (count: number) => void; includeLabels?: string[]; excludeLabels?: string[]; @@ -108,6 +110,7 @@ export async function collectPullRequests( baseUrl: params.baseUrl, }), baseUrl: params.baseUrl, + rejectUnauthorized: params.rejectUnauthorized, }); const since = new Date(params.since); const prs: RawPullRequest[] = []; diff --git a/src/index.ts b/src/index.ts index 7471f17..127536d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import "./plugins/builtins.js"; + export { collectPullRequests } from "./collectors/pullRequests.js"; export { calculateCycleTime } from "./calculators/cycleTime.js"; export { calculateReviewMetrics } from "./calculators/reviewMetrics.js"; diff --git a/src/plugins/builtins.ts b/src/plugins/builtins.ts new file mode 100644 index 0000000..21bb2d3 --- /dev/null +++ b/src/plugins/builtins.ts @@ -0,0 +1,11 @@ +import "../calculators/changeRequestRatio.js"; +import "../calculators/commentDensity.js"; +import "../calculators/cycleTime.js"; +import "../calculators/idleTimeHours.js"; +import "../calculators/reviewerCount.js"; +import "../calculators/revertRate.js"; +import "../calculators/ciPassRate.js"; +import "../calculators/ciMetrics.js"; +import "../calculators/reviewMetrics.js"; +import "../calculators/sizeBucket.js"; +import "../calculators/outsizedFlag.js"; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b78fc6b..46f3d37 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -9,16 +9,3 @@ export function register(plugin: MetricPlugin): void { export function getAll(): MetricPlugin[] { return [...plugins]; } - -// Auto-register built-in calculators -import "../calculators/changeRequestRatio.js"; -import "../calculators/commentDensity.js"; -import "../calculators/cycleTime.js"; -import "../calculators/idleTimeHours.js"; -import "../calculators/reviewerCount.js"; -import "../calculators/revertRate.js"; -import "../calculators/ciPassRate.js"; -import "../calculators/ciMetrics.js"; -import "../calculators/reviewMetrics.js"; -import "../calculators/sizeBucket.js"; -import "../calculators/outsizedFlag.js"; diff --git a/test/fixtures/test-cert.pem b/test/fixtures/test-cert.pem new file mode 100644 index 0000000..dff7521 --- /dev/null +++ b/test/fixtures/test-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUI1udxd76Ev2BdlEoVPyqlVBgof4wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDgwODE1MTYyMFoXDTI1MDgw +OTE1MTYyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAwQ31IC+kB0puRg5CBwFT6FPQy7Lve8ZJaDN1ANSZv6Zv +DWnhN523lAzV4bQH8i0ut6YljIXfwgq98Qk4WAoC1OSLJWe/fwkZffIi6KBC+sD6 +Ws0QvEcXCqEo+l8ouO3LS0Mr5slsrxI1yv0hBNcd9XRHY2iU6yzYI3y2Bcviwwjc +OOR0bhg4dOSbRX8VRYeDA/he2H43Hh7XebUjo1SaZVZgPIKQBj/JfbJBwYpG702W +6hHw+AXEXSMI/0ldvy6uyl9RmbV+x6IPokI4/CqPRKvL09tjWQXnAVNJaTyPzi+3 +wmeef8srnRQZztf4p6wThnodIyKnzLboIiJGZok3owIDAQABo1MwUTAdBgNVHQ4E +FgQUQbNo+9+Oi9ImAbxsZ3xFkrHXZZUwHwYDVR0jBBgwFoAUQbNo+9+Oi9ImAbxs +Z3xFkrHXZZUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYKV3 +GK5q33S3M0ne/77l8IPHsig42fUl1IEMO/qvrKoCO6aiKZLHik4LoNIpLB/sxgMx +IqvoPSXyBBegRRmnephxWyZTBWgIWUiQOenf2PdSLnTHEYCCtjcgZpPoXELw0L84 +0dtM4OcHf060dfABbWdEhJYbYXHvc4mx74WG7eFFuuNGzQJnZn4DNjK0kAvNZPKa +ll3SjQ5/+VyD8PAmYcQn/2NkTf8GtnIeBsaEEIRvcvosWRTBiyvHiSdg2PtLsqgK +xcCN6BDz7n5RuuxEL+WmTla9Dn5lXN/fopnRwlVGSmBwbn4IENNOiqC4eL6m/qC9 +sw2ZNJF0UqSagebduA== +-----END CERTIFICATE----- diff --git a/test/fixtures/test-key.pem b/test/fixtures/test-key.pem new file mode 100644 index 0000000..e44ae75 --- /dev/null +++ b/test/fixtures/test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDBDfUgL6QHSm5G +DkIHAVPoU9DLsu97xkloM3UA1Jm/pm8NaeE3nbeUDNXhtAfyLS63piWMhd/CCr3x +CThYCgLU5IslZ79/CRl98iLooEL6wPpazRC8RxcKoSj6Xyi47ctLQyvmyWyvEjXK +/SEE1x31dEdjaJTrLNgjfLYFy+LDCNw45HRuGDh05JtFfxVFh4MD+F7YfjceHtd5 +tSOjVJplVmA8gpAGP8l9skHBikbvTZbqEfD4BcRdIwj/SV2/Lq7KX1GZtX7Hog+i +Qjj8Ko9Eq8vT22NZBecBU0lpPI/OL7fCZ55/yyudFBnO1/inrBOGeh0jIqfMtugi +IkZmiTejAgMBAAECggEAJbHwfX7f486MNqCdEtoXBKOs/sErErt2ZOK7Q1S/ypSY +VYGpiJ8Ck+Vns8Df1lEtMI3AAi63sJ9BVDmtJz+ZKvGPG9gko4zmzdlEKLD2MxkQ +KT+mN2UYIiqyoiSHgohn7AOrJE6LKqn/F+oSEuxf/KfeMpJelYb1kwRMVFhpqBQR +YZlO5JWa4VYw2EGby47GCiq1oNNeBiaTzgwPmdD5a6hOlWZksJ5SBzF5IAE9pAOE +UXwhV/g8a/CR8RMcpnB3o/Fz4sCGu00mJFBimXpsoajgadycRrwagtExTbs4Si9u +xwxGw6rGX48y+6vi7e35paWPwpjAPrPdoAY1wQkD1QKBgQDqDlq7t4+osWIaqwLq +cRHkJZwyg4EG7X/Xm7Odik9vz//HRXLFU631lM5Jc7BLOsluELbRqb0Ys35V6W1Y +I+mM4qkwS09E8YypmaCjwERGlYpwKqEwW7sCQ0tVLrUchBviPJug31k8+FSQVqiu +YlVQn/wLAdznUH8HSM6+/w/ZTwKBgQDTJ4Nrg5l8pk90a9K9bTrXsgDAlPJUChQa +NkC1SHi+QtdWgLW0kj2XgMCDjXi5RZpPGXbpzyXDYCnWnLmrAr75YsXypj6+1gk0 +1LyINAHE7Sg37S95NjNVCNuCa0WfNpkC4BxGxJL6/YBCQzkM/26EK7ebwSLONbye +ORDVymz/bQKBgG7DGEEZXwyv1QNVxm130MWs3rww61CB+CvqbReyqmD5h8ufm+6x +6PL81pp/+v++9C/4DOwvbWNRHgo+CxbY999KLFLEcODphm2EXDbh9+2HOxpVsi4W +Wut5OR84mVDDXAa2M9fvmmV2B16/A9hyhHlBHJ+A5C87MsIZvG06ex+VAoGBALXP ++hrjL4/i69tE1CqDGP2MqZUpKxctrm17dXAivMSmbSBwpwSaPypqlFkxwVdWFS4y +sO9VjCCUdrHyNPSI4sN9RxBOEQzk4vx4iPWGQle8FLtDIXNvNffsLoY3s7UJn+qX +6gENVbmD+aIUm9UTQ6fOtMQKItwH7ScX3+UV7ZBpAoGAYxe3PP6EOFcwNhfFETqR +svtCAtjuZe5enwdALoVdgRnFIzI92/6FyD4sDh3o6gVuZ0rr/Cxtyw94TFpcNpC9 +X28N/u7VKL6XpEj5kxZRN3aFu01T7fvQABIL4ovDpaBPRnbjZCEkXgwOtFQ9jEbQ +FWITXRUboToPaLDFVRT7I3w= +-----END PRIVATE KEY----- diff --git a/test/playground-fetch-insecure.test.ts b/test/playground-fetch-insecure.test.ts new file mode 100644 index 0000000..3fefe5d --- /dev/null +++ b/test/playground-fetch-insecure.test.ts @@ -0,0 +1,76 @@ +import https from "https"; +import fs from "fs"; +import path from "path"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +const key = fs.readFileSync(path.resolve(__dirname, "fixtures/test-key.pem")); +const cert = fs.readFileSync(path.resolve(__dirname, "fixtures/test-cert.pem")); + +function startServer() { + return new Promise((resolve) => { + const server = https + .createServer({ key, cert }, (req, res) => { + let body = ""; + req.on("data", (d) => (body += d)); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + }, + }), + ); + }); + }) + .listen(0, () => resolve(server)); + }); +} + +describe("self-signed certificates", () => { + let server: https.Server; + let url: string; + beforeEach(async () => { + server = await startServer(); + const { port } = server.address() as any; + url = `https://localhost:${port}`; + }); + afterEach(() => new Promise((resolve) => server.close(() => resolve()))); + + it("fails by default", async () => { + await expect( + execFileAsync("node", [ + path.resolve(__dirname, "../playground/fetch-metrics.mjs"), + "owner/repo", + "--base-url", + url, + ], { + env: { ...process.env, GH_TOKEN: "token" }, + }), + ).rejects.toThrow(); + }); + + it("succeeds when insecure flag is used", async () => { + const { stdout } = await execFileAsync( + "node", + [ + path.resolve(__dirname, "../playground/fetch-metrics.mjs"), + "owner/repo", + "--base-url", + url, + "--insecure", + ], + { env: { ...process.env, GH_TOKEN: "token" } }, + ); + const metrics = JSON.parse(stdout); + expect(metrics.mergeRate).toBe(0); + }); +}); diff --git a/test/playground-fetch.test.ts b/test/playground-fetch.test.ts new file mode 100644 index 0000000..6b132de --- /dev/null +++ b/test/playground-fetch.test.ts @@ -0,0 +1,14 @@ +import path from "path"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +describe("playground fetch example", () => { + it("runs and returns metrics", async () => { + const script = path.resolve(__dirname, "scripts/run-fetch-example.mjs"); + const { stdout } = await execFileAsync("node", [script]); + const metrics = JSON.parse(stdout); + expect(metrics.mergeRate).toBe(0); + }); +}); diff --git a/test/scripts/run-fetch-example.mjs b/test/scripts/run-fetch-example.mjs new file mode 100644 index 0000000..2407daa --- /dev/null +++ b/test/scripts/run-fetch-example.mjs @@ -0,0 +1,26 @@ +import nock from "nock"; +import { fetchMetrics } from "../../playground/fetch-metrics.mjs"; + +process.env.GH_TOKEN = "token"; + +nock("https://api.github.com") + .post("/graphql") + .reply(200, { + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + }, + }); + +fetchMetrics("owner/repo") + .then((m) => { + console.log(JSON.stringify(m)); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); From 8f6a6b96efcbb2394713c6245561d8c2b85a2a9c Mon Sep 17 00:00:00 2001 From: Jason Humphrey Date: Sat, 9 Aug 2025 14:57:20 -0500 Subject: [PATCH 2/5] fix: derive check suites from commits --- src/collectors/pullRequests.ts | 37 ++++++------ src/collectors/pullRequests.types.ts | 7 +-- src/models/index.ts | 6 +- test/collectPullRequests.test.ts | 90 ++++++++++++++++++++++++---- 4 files changed, 101 insertions(+), 39 deletions(-) diff --git a/src/collectors/pullRequests.ts b/src/collectors/pullRequests.ts index 8bdf949..02833d3 100644 --- a/src/collectors/pullRequests.ts +++ b/src/collectors/pullRequests.ts @@ -51,6 +51,21 @@ export interface CollectPullRequestsParams { } function mapPR(pr: GraphqlPullRequest): RawPullRequest { + const commits = pr.commits.nodes.map((c) => ({ + oid: c.commit.oid, + messageHeadline: c.commit.messageHeadline, + committedDate: c.commit.committedDate, + checkSuites: c.commit.checkSuites.nodes.map((cs) => ({ + id: cs.id, + status: cs.status, + conclusion: cs.conclusion, + startedAt: cs.startedAt, + completedAt: cs.completedAt, + })), + })); + + const checkSuites = commits.flatMap((c) => c.checkSuites); + return { id: pr.id, number: pr.number, @@ -73,21 +88,8 @@ function mapPR(pr: GraphqlPullRequest): RawPullRequest { createdAt: c.createdAt, author: c.author ? { login: c.author.login } : null, })), - commits: pr.commits.nodes.map((c) => ({ - oid: c.commit.oid, - messageHeadline: c.commit.messageHeadline, - committedDate: c.commit.committedDate, - checkSuites: c.commit.checkSuites.nodes.map((cs) => ({ - conclusion: cs.conclusion, - })), - })), - checkSuites: pr.checkSuites.nodes.map((c) => ({ - id: c.id, - status: c.status, - conclusion: c.conclusion, - startedAt: c.startedAt, - completedAt: c.completedAt, - })), + commits, + checkSuites, timelineItems: pr.timelineItems.nodes.map((t) => ({ type: t.__typename, createdAt: t.createdAt, @@ -138,9 +140,8 @@ export async function collectPullRequests( author{login} reviews(first:100){nodes{id state submittedAt author{login}}} comments(first:100){nodes{id body createdAt author{login}}} - commits(last:100){nodes{commit{oid committedDate messageHeadline checkSuites(first:100){nodes{conclusion}}}}} - checkSuites(first:100){nodes{id status conclusion startedAt completedAt}} - timelineItems(first:100,itemTypes:[READY_FOR_REVIEW,REVIEW_REQUESTED]){nodes{__typename ... on ReadyForReviewEvent{createdAt} ... on ReviewRequestedEvent{createdAt}}} + commits(last:100){nodes{commit{oid committedDate messageHeadline checkSuites(first:100){nodes{id status conclusion startedAt completedAt}}}}} + timelineItems(first:100,itemTypes:[READY_FOR_REVIEW_EVENT,REVIEW_REQUESTED_EVENT]){nodes{__typename ... on ReadyForReviewEvent{createdAt} ... on ReviewRequestedEvent{createdAt}}} } } } diff --git a/src/collectors/pullRequests.types.ts b/src/collectors/pullRequests.types.ts index 61a5ce2..d5c3875 100644 --- a/src/collectors/pullRequests.types.ts +++ b/src/collectors/pullRequests.types.ts @@ -21,14 +21,10 @@ export interface GraphqlCommit { oid: string; messageHeadline: string; committedDate: string; - checkSuites: { nodes: GraphqlCommitCheckSuite[] }; + checkSuites: { nodes: GraphqlCheckSuite[] }; }; } -export interface GraphqlCommitCheckSuite { - conclusion: string | null; -} - export interface GraphqlTimelineItem { __typename: string; createdAt: string; @@ -59,7 +55,6 @@ export interface GraphqlPullRequest { reviews: { nodes: GraphqlReview[] }; comments: { nodes: GraphqlComment[] }; commits: { nodes: GraphqlCommit[] }; - checkSuites: { nodes: GraphqlCheckSuite[] }; timelineItems: { nodes: GraphqlTimelineItem[] }; } diff --git a/src/models/index.ts b/src/models/index.ts index 6f1e87f..84769fe 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -31,11 +31,7 @@ export interface Commit { oid: string; messageHeadline: string; committedDate: string; - checkSuites: CommitCheckSuite[]; -} - -export interface CommitCheckSuite { - conclusion: string | null; + checkSuites: CheckSuite[]; } export interface TimelineItem { diff --git a/test/collectPullRequests.test.ts b/test/collectPullRequests.test.ts index 9e8d6b2..1c6e27b 100644 --- a/test/collectPullRequests.test.ts +++ b/test/collectPullRequests.test.ts @@ -78,7 +78,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, { @@ -98,7 +97,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, ] as GraphqlPullRequest[], @@ -130,7 +128,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, { @@ -150,7 +147,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, ] as GraphqlPullRequest[], @@ -199,7 +195,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, ] as GraphqlPullRequest[], @@ -219,6 +214,86 @@ describe("collectPullRequests", () => { expect(counts).toEqual([1]); }); + it("flattens commit check suites and uses correct timeline enums", async () => { + let capturedQuery = ""; + nock(baseUrl) + .post("/graphql", (body: any) => { + capturedQuery = body.query; + return queryRegex.test(body.query); + }) + .reply(200, { + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "1", + number: 1, + title: "pr1", + state: "OPEN", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + mergedAt: null, + closedAt: null, + additions: 1, + deletions: 1, + changedFiles: 1, + labels: { nodes: [] }, + author: { login: "a" }, + reviews: { nodes: [] }, + comments: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + oid: "c1", + messageHeadline: "msg", + committedDate: "2024-01-01T00:00:00Z", + checkSuites: { + nodes: [ + { + id: "s1", + status: "COMPLETED", + conclusion: "SUCCESS", + startedAt: "2024-01-01T00:00:00Z", + completedAt: "2024-01-01T00:00:10Z", + }, + { + id: "s2", + status: "COMPLETED", + conclusion: "FAILURE", + startedAt: "2024-01-01T01:00:00Z", + completedAt: "2024-01-01T01:00:20Z", + }, + ], + }, + }, + }, + ], + }, + timelineItems: { nodes: [] }, + }, + ] as GraphqlPullRequest[], + }, + }, + }, + }); + + const prs = await collectPullRequests({ + owner: "me", + repo: "repo", + since, + auth, + baseUrl, + }); + + expect(prs[0]?.checkSuites).toHaveLength(2); + expect(capturedQuery).toContain("READY_FOR_REVIEW_EVENT"); + expect(capturedQuery).toContain("REVIEW_REQUESTED_EVENT"); + expect(capturedQuery.match(/checkSuites\(first:100\)/g)).toHaveLength(1); + }); + it("extracts ticket info from title", async () => { nock(baseUrl) .post("/graphql") @@ -245,7 +320,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, ] as GraphqlPullRequest[], @@ -290,7 +364,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, { @@ -310,7 +383,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, ] as GraphqlPullRequest[], @@ -357,7 +429,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }, ] as GraphqlPullRequest[], @@ -425,7 +496,6 @@ describe("collectPullRequests", () => { reviews: { nodes: [] }, comments: { nodes: [] }, commits: { nodes: [] }, - checkSuites: { nodes: [] }, timelineItems: { nodes: [] }, }); From 2de8f824ea3c829968fb8cb11400df68c022693d Mon Sep 17 00:00:00 2001 From: Jason Humphrey Date: Sat, 9 Aug 2025 15:39:35 -0500 Subject: [PATCH 3/5] test: add live collectPullRequests example --- src/calculators/ciMetrics.ts | 4 ++-- src/calculators/metrics.ts | 4 ++-- src/collectors/pullRequests.ts | 6 +++--- src/collectors/pullRequests.types.ts | 4 ++-- src/models/index.ts | 4 ++-- test/ciMetrics.test.ts | 8 ++++---- test/ciPassRate.test.ts | 4 ++-- test/collectPullRequests.test.ts | 8 ++++---- test/live-collectPullRequests.test.ts | 15 +++++++++++++++ test/metrics.test.ts | 8 ++++---- 10 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 test/live-collectPullRequests.test.ts diff --git a/src/calculators/ciMetrics.ts b/src/calculators/ciMetrics.ts index 89c86a8..f729f45 100644 --- a/src/calculators/ciMetrics.ts +++ b/src/calculators/ciMetrics.ts @@ -21,8 +21,8 @@ export function calculateCiMetrics(pr: RawPullRequest): CiMetrics { for (const suite of pr.checkSuites) { total += 1; if (suite.conclusion === "SUCCESS") success += 1; - const start = Date.parse(suite.startedAt); - const end = Date.parse(suite.completedAt); + const start = Date.parse(suite.createdAt); + const end = Date.parse(suite.updatedAt); if (!Number.isNaN(start) && !Number.isNaN(end)) { durationTotal += end - start; } diff --git a/src/calculators/metrics.ts b/src/calculators/metrics.ts index 8e88408..7595c41 100644 --- a/src/calculators/metrics.ts +++ b/src/calculators/metrics.ts @@ -96,8 +96,8 @@ export function calculateMetrics( for (const cs of pr.checkSuites) { checkSuiteCount += 1; if (cs.conclusion === "SUCCESS") buildSuccess += 1; - const start = Date.parse(cs.startedAt); - const end = Date.parse(cs.completedAt); + const start = Date.parse(cs.createdAt); + const end = Date.parse(cs.updatedAt); if (!Number.isNaN(start) && !Number.isNaN(end)) { totalCiDuration += end - start; } diff --git a/src/collectors/pullRequests.ts b/src/collectors/pullRequests.ts index 02833d3..a7db74a 100644 --- a/src/collectors/pullRequests.ts +++ b/src/collectors/pullRequests.ts @@ -59,8 +59,8 @@ function mapPR(pr: GraphqlPullRequest): RawPullRequest { id: cs.id, status: cs.status, conclusion: cs.conclusion, - startedAt: cs.startedAt, - completedAt: cs.completedAt, + createdAt: cs.createdAt, + updatedAt: cs.updatedAt, })), })); @@ -140,7 +140,7 @@ export async function collectPullRequests( author{login} reviews(first:100){nodes{id state submittedAt author{login}}} comments(first:100){nodes{id body createdAt author{login}}} - commits(last:100){nodes{commit{oid committedDate messageHeadline checkSuites(first:100){nodes{id status conclusion startedAt completedAt}}}}} + commits(last:100){nodes{commit{oid committedDate messageHeadline checkSuites(first:100){nodes{id status conclusion createdAt updatedAt}}}}} timelineItems(first:100,itemTypes:[READY_FOR_REVIEW_EVENT,REVIEW_REQUESTED_EVENT]){nodes{__typename ... on ReadyForReviewEvent{createdAt} ... on ReviewRequestedEvent{createdAt}}} } } diff --git a/src/collectors/pullRequests.types.ts b/src/collectors/pullRequests.types.ts index d5c3875..2c5ea9d 100644 --- a/src/collectors/pullRequests.types.ts +++ b/src/collectors/pullRequests.types.ts @@ -34,8 +34,8 @@ export interface GraphqlCheckSuite { id: string; status: string; conclusion: string | null; - startedAt: string; - completedAt: string; + createdAt: string; + updatedAt: string; } export interface GraphqlPullRequest { diff --git a/src/models/index.ts b/src/models/index.ts index 84769fe..416ce9d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -55,8 +55,8 @@ export interface CheckSuite { id: string; status: string; conclusion: string | null; - startedAt: string; - completedAt: string; + createdAt: string; + updatedAt: string; } /** diff --git a/test/ciMetrics.test.ts b/test/ciMetrics.test.ts index e1ff784..eccdde1 100644 --- a/test/ciMetrics.test.ts +++ b/test/ciMetrics.test.ts @@ -31,15 +31,15 @@ describe("calculateCiMetrics", () => { id: "1", status: "COMPLETED", conclusion: "SUCCESS", - startedAt: "2024-01-01T00:00:00Z", - completedAt: "2024-01-01T00:00:10Z", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:10Z", }, { id: "2", status: "COMPLETED", conclusion: "FAILURE", - startedAt: "2024-01-01T01:00:00Z", - completedAt: "2024-01-01T01:00:20Z", + createdAt: "2024-01-01T01:00:00Z", + updatedAt: "2024-01-01T01:00:20Z", }, ], }; diff --git a/test/ciPassRate.test.ts b/test/ciPassRate.test.ts index ed936f3..def857a 100644 --- a/test/ciPassRate.test.ts +++ b/test/ciPassRate.test.ts @@ -27,8 +27,8 @@ describe("calculateCiPassRate", () => { const pr = { ...base, checkSuites: [ - { id: "1", status: "COMPLETED", conclusion: "SUCCESS", startedAt: base.createdAt, completedAt: base.createdAt }, - { id: "2", status: "COMPLETED", conclusion: "FAILURE", startedAt: base.createdAt, completedAt: base.createdAt }, + { id: "1", status: "COMPLETED", conclusion: "SUCCESS", createdAt: base.createdAt, updatedAt: base.createdAt }, + { id: "2", status: "COMPLETED", conclusion: "FAILURE", createdAt: base.createdAt, updatedAt: base.createdAt }, ], }; expect(calculateCiPassRate(pr)).toBeCloseTo(0.5); diff --git a/test/collectPullRequests.test.ts b/test/collectPullRequests.test.ts index 1c6e27b..76504e9 100644 --- a/test/collectPullRequests.test.ts +++ b/test/collectPullRequests.test.ts @@ -256,15 +256,15 @@ describe("collectPullRequests", () => { id: "s1", status: "COMPLETED", conclusion: "SUCCESS", - startedAt: "2024-01-01T00:00:00Z", - completedAt: "2024-01-01T00:00:10Z", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:10Z", }, { id: "s2", status: "COMPLETED", conclusion: "FAILURE", - startedAt: "2024-01-01T01:00:00Z", - completedAt: "2024-01-01T01:00:20Z", + createdAt: "2024-01-01T01:00:00Z", + updatedAt: "2024-01-01T01:00:20Z", }, ], }, diff --git a/test/live-collectPullRequests.test.ts b/test/live-collectPullRequests.test.ts new file mode 100644 index 0000000..04fc9a9 --- /dev/null +++ b/test/live-collectPullRequests.test.ts @@ -0,0 +1,15 @@ +const token = process.env["GITHUB_TOKEN"]; + +(token ? describe : describe.skip)("collectPullRequests live", () => { + it("fetches pull requests using the real GitHub API", async () => { + // @ts-expect-error compiled JS has no types + const { collectPullRequests } = await import("../dist/collectors/pullRequests.js"); + const prs = await collectPullRequests({ + owner: "octocat", + repo: "Hello-World", + since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + auth: token!, + }); + expect(Array.isArray(prs)).toBe(true); + }); +}); diff --git a/test/metrics.test.ts b/test/metrics.test.ts index e6ac875..befd5f8 100644 --- a/test/metrics.test.ts +++ b/test/metrics.test.ts @@ -77,15 +77,15 @@ describe("calculateMetrics", () => { id: "1", status: "COMPLETED", conclusion: "SUCCESS", - startedAt: base.createdAt, - completedAt: base.createdAt, + createdAt: base.createdAt, + updatedAt: base.createdAt, }, { id: "2", status: "COMPLETED", conclusion: "FAILURE", - startedAt: base.createdAt, - completedAt: base.createdAt, + createdAt: base.createdAt, + updatedAt: base.createdAt, }, ], }; From a47b0aee0e0a2b934ff1c2ffa39a61e3d801f99c Mon Sep 17 00:00:00 2001 From: Jason Humphrey Date: Sat, 9 Aug 2025 20:40:48 -0500 Subject: [PATCH 4/5] Handle GraphQL node limit by shrinking page size --- src/collectors/pullRequests.ts | 20 +++++++++++++++++--- test/collectPullRequests.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/collectors/pullRequests.ts b/src/collectors/pullRequests.ts index a7db74a..672c87b 100644 --- a/src/collectors/pullRequests.ts +++ b/src/collectors/pullRequests.ts @@ -129,9 +129,10 @@ export async function collectPullRequests( if (saved?.updatedAt) lastUpdated = saved.updatedAt; } let hasNextPage = true; - const query = `query($owner:String!,$repo:String!,$cursor:String){ + let pageSize = 100; + const query = `query($owner:String!,$repo:String!,$cursor:String,$pageSize:Int!){ repository(owner:$owner,name:$repo){ - pullRequests(first:100,after:$cursor,orderBy:{field:UPDATED_AT,direction:DESC}){ + pullRequests(first:$pageSize,after:$cursor,orderBy:{field:UPDATED_AT,direction:DESC}){ pageInfo{hasNextPage,endCursor} nodes{ id number title state createdAt updatedAt mergedAt closedAt @@ -150,13 +151,17 @@ export async function collectPullRequests( let retries = 0; while (hasNextPage) { try { - const key: string = createHash("sha1").update(String(cursor)).digest("hex"); + const key: string = createHash("sha1") + .update(String(cursor)) + .update(String(pageSize)) + .digest("hex"); let data: PullRequestsQuery | undefined = params.cache?.get(key); if (!data) { data = (await graphqlWithRetry(client, query, { owner: params.owner, repo: params.repo, cursor, + pageSize, })) as PullRequestsQuery; params.cache?.set(key, data); } @@ -202,6 +207,15 @@ export async function collectPullRequests( retries += 1; continue; } + if ( + (err.errors?.some((e: any) => e.type === "MAX_NODE_LIMIT_EXCEEDED") || + /MAX_NODE_LIMIT_EXCEEDED/.test(err.message)) && + pageSize > 1 + ) { + pageSize = Math.max(1, Math.floor(pageSize / 2)); + retries = 0; + continue; + } if (params.resume && params.cache) { params.cache.set(cacheCursorKey, { cursor, updatedAt: lastUpdated }); } diff --git a/test/collectPullRequests.test.ts b/test/collectPullRequests.test.ts index 76504e9..1a19e79 100644 --- a/test/collectPullRequests.test.ts +++ b/test/collectPullRequests.test.ts @@ -538,4 +538,34 @@ describe("collectPullRequests", () => { process.env["HOME"] = origHome; fs.rmSync(tmp, { recursive: true, force: true }); }); + + it("shrinks page size when node limit exceeded", async () => { + const scope = nock(baseUrl, { + reqheaders: { authorization: `token ${auth}` }, + }) + .post("/graphql", (body) => body.variables.pageSize === 100) + .reply(200, { errors: [{ type: "MAX_NODE_LIMIT_EXCEEDED" }] }) + .post("/graphql", (body) => body.variables.pageSize === 50) + .reply(200, { + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + }, + }); + + const prs = await collectPullRequests({ + owner: "me", + repo: "repo", + since, + auth, + baseUrl, + }); + + expect(prs).toEqual([]); + scope.done(); + }); }); From e11c362352d2c7a0699027e7cb6e478f756a15af Mon Sep 17 00:00:00 2001 From: Jason Humphrey Date: Sun, 10 Aug 2025 15:59:45 -0500 Subject: [PATCH 5/5] Fix lint and test failures --- test/cli.test.ts | 90 ++++++++++++++++---------------- test/collectPullRequests.test.ts | 5 +- 2 files changed, 49 insertions(+), 46 deletions(-) diff --git a/test/cli.test.ts b/test/cli.test.ts index e2d8463..185290c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -55,10 +55,11 @@ jest.mock("../src/cache/sqliteStore", () => ({ sqliteStore: jest.fn(() => ({})), })); -import fs from "fs"; -import os from "os"; -import path from "path"; import logger from "../src/logger.js"; +import { runCli } from "../src/cli"; +import { collectPullRequests } from "../src/collectors/pullRequests"; +import { sqliteStore } from "../src/cache/sqliteStore"; +import * as writers from "../src/output/writers"; describe("cli", () => { const origArgv = process.argv; @@ -71,13 +72,38 @@ describe("cli", () => { (logger.info as jest.Mock).mockClear(); (logger.error as jest.Mock).mockClear(); stdout.mockClear(); - jest.resetModules(); jest.clearAllMocks(); + (collectPullRequests as jest.Mock).mockImplementation(async () => [ + { + id: "1", + number: 1, + title: "t", + state: "OPEN", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + mergedAt: "2024-01-02T00:00:00Z", + closedAt: null, + additions: 1, + deletions: 1, + changedFiles: 1, + labels: [], + author: null, + reviews: [ + { + id: "r1", + state: "APPROVED", + submittedAt: "2024-01-01T12:00:00Z", + author: null, + }, + ], + comments: [], + commits: [], + checkSuites: [], + }, + ]); }); it("prints JSON metrics", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; process.argv = ["node", "cli", "foo/bar", "--token", "t"]; await runCli(); expect(stdout).toHaveBeenCalledTimes(1); @@ -88,23 +114,16 @@ describe("cli", () => { }); it("supports dry run", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; process.argv = ["node", "cli", "foo/bar", "--token", "t", "--dry-run"]; await runCli(); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining("Would fetch metrics"), ); - expect( - (require("../src/collectors/pullRequests") as any).collectPullRequests, - ).not.toHaveBeenCalled(); + expect(collectPullRequests).not.toHaveBeenCalled(); }); it("prints progress information", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const mod = require("../src/collectors/pullRequests"); - mod.collectPullRequests.mockImplementation(async (opts: any) => { + (collectPullRequests as jest.Mock).mockImplementation(async (opts: any) => { opts.onProgress(1); opts.onProgress(2); return []; @@ -119,11 +138,7 @@ describe("cli", () => { }); it("writes metrics to stderr", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const errSpy = jest - .spyOn(process.stderr, "write") - .mockImplementation(() => true); + const outSpy = jest.spyOn(writers, "writeOutput"); process.argv = [ "node", "cli", @@ -134,14 +149,14 @@ describe("cli", () => { "stderr", ]; await runCli(); - expect(errSpy).toHaveBeenCalled(); - errSpy.mockRestore(); + expect(outSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ destination: "stderr" }), + ); + outSpy.mockRestore(); }); it("passes label filters", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const mod = require("../src/collectors/pullRequests"); process.argv = [ "node", "cli", @@ -154,7 +169,7 @@ describe("cli", () => { "wip", ]; await runCli(); - expect(mod.collectPullRequests).toHaveBeenCalledWith( + expect(collectPullRequests).toHaveBeenCalledWith( expect.objectContaining({ includeLabels: ["team-a", "team-b"], excludeLabels: ["wip"], @@ -163,38 +178,28 @@ describe("cli", () => { }); it("uses cache when enabled", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const mod = require("../src/collectors/pullRequests"); - const cacheMod = require("../src/cache/sqliteStore"); process.argv = ["node", "cli", "foo/bar", "--token", "t", "--use-cache"]; await runCli(); - expect(cacheMod.sqliteStore).toHaveBeenCalled(); - expect(mod.collectPullRequests).toHaveBeenCalledWith( + expect(sqliteStore).toHaveBeenCalled(); + expect(collectPullRequests).toHaveBeenCalledWith( expect.objectContaining({ cache: expect.any(Object) }) ); }); it("passes --resume to collector", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const mod = require("../src/collectors/pullRequests"); process.argv = ["node", "cli", "foo/bar", "--token", "t", "--resume"]; await runCli(); - expect(mod.collectPullRequests).toHaveBeenCalledWith( + expect(collectPullRequests).toHaveBeenCalledWith( expect.objectContaining({ resume: true }) ); }); it("parses --since values", async () => { jest.useFakeTimers().setSystemTime(new Date("2024-05-20T00:00:00Z")); - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const mod = require("../src/collectors/pullRequests"); process.argv = ["node", "cli", "foo/bar", "--token", "t", "--since", "2d"]; await runCli(); jest.useRealTimers(); - expect(mod.collectPullRequests).toHaveBeenCalledWith( + expect(collectPullRequests).toHaveBeenCalledWith( expect.objectContaining({ since: new Date("2024-05-18T00:00:00.000Z").toISOString(), }), @@ -202,15 +207,12 @@ describe("cli", () => { }); it("errors on invalid --since", async () => { - const { runCli } = require("../src/cli"); - const logger = require("../src/logger.js").default; - const mod = require("../src/collectors/pullRequests"); process.argv = ["node", "cli", "foo/bar", "--token", "t", "--since", "bad"]; await runCli(); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("Invalid duration"), ); - expect(mod.collectPullRequests).not.toHaveBeenCalled(); + expect(collectPullRequests).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); process.exitCode = 0; }); diff --git a/test/collectPullRequests.test.ts b/test/collectPullRequests.test.ts index 1a19e79..ac0c3b1 100644 --- a/test/collectPullRequests.test.ts +++ b/test/collectPullRequests.test.ts @@ -449,7 +449,7 @@ describe("collectPullRequests", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cache-")); const origHome = process.env["HOME"]; process.env["HOME"] = tmp; - const { sqliteStore } = require("../src/cache/sqliteStore"); + const { sqliteStore } = await import("../src/cache/sqliteStore"); const cache = sqliteStore(); const scope = nock(baseUrl) @@ -468,6 +468,7 @@ describe("collectPullRequests", () => { await collectPullRequests({ owner: "me", repo: "r", since, auth, baseUrl, cache }); await collectPullRequests({ owner: "me", repo: "r", since, auth, baseUrl, cache }); + scope.done(); process.env["HOME"] = origHome; fs.rmSync(tmp, { recursive: true, force: true }); }); @@ -476,7 +477,7 @@ describe("collectPullRequests", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cache-")); const origHome = process.env["HOME"]; process.env["HOME"] = tmp; - const { sqliteStore } = require("../src/cache/sqliteStore"); + const { sqliteStore } = await import("../src/cache/sqliteStore"); const cache = sqliteStore(); const makePr = (n: number): GraphqlPullRequest => ({