diff --git a/package.json b/package.json index 2ccd50f..fd5f855 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.68.3", + "version": "0.68.4", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/gh.ts b/src/gh.ts index 97db2f6..4757a6d 100644 --- a/src/gh.ts +++ b/src/gh.ts @@ -106,6 +106,8 @@ export interface PullRequest { export interface Check { name: string; bucket: Bucket; + /** The check's details page; for Actions, `.../actions/runs//job/`. */ + link?: string; } function fail(field: string, value: unknown, where: string): never { @@ -163,10 +165,12 @@ export function parseChecks(raw: unknown, where = 'gh pr checks'): Check[] { return raw.map((entry) => { const record = entry as Record; - return { + const check: Check = { name: asString(record.name, 'name', where), bucket: asEnum(BUCKET, record.bucket, 'bucket', where), }; + if (typeof record.link === 'string' && record.link) check.link = record.link; + return check; }); } @@ -398,7 +402,7 @@ export class Gh { async checks(url: string): Promise { try { return await this.json( - ['pr', 'checks', url, '--json', 'bucket,name'], + ['pr', 'checks', url, '--json', 'bucket,name,link'], (raw) => parseChecks(raw), { allowNonZero: true }, ); @@ -470,6 +474,56 @@ export class Gh { ); } + /** + * Red Actions checks whose job GitHub never started, with GitHub's reason. + * + * A billing block or account lock fails every job in seconds with no runner + * and no log, so `gh pr checks` reports `test=fail` exactly as it would for a + * broken test. The only place the truth is written is the check run's + * annotation ("The job was not started because recent account payments have + * failed…"). Reported as `test=fail`, it sends whoever reads the sweep off to + * debug code that never ran. + * + * Best effort: an annotation that cannot be read leaves the plain reason. + */ + async refusedToStart( + url: string, + checks: Check[], + ): Promise<{ names: string[]; message: string } | null> { + const target = parsePullRequestUrl(url); + if (!target) return null; + + const names: string[] = []; + let message = ''; + + for (const check of checks) { + if (check.bucket !== 'fail') continue; + const job = check.link?.match(/\/actions\/runs\/\d+\/job\/(\d+)/)?.[1]; + if (!job) continue; + + const result = await this.call(['api', `repos/${target.slug}/check-runs/${job}/annotations`]); + if (result.code !== 0) continue; + + let annotations: unknown; + try { + annotations = JSON.parse(result.stdout); + } catch { + continue; + } + if (!Array.isArray(annotations)) continue; + + const refusal = annotations + .map((a) => (a as Record).message) + .find((m): m is string => typeof m === 'string' && /job was not started/i.test(m)); + if (refusal) { + names.push(check.name); + message ||= refusal.trim(); + } + } + + return names.length > 0 ? { names, message } : null; + } + async approveRun(url: string, runId: number): Promise { const target = parsePullRequestUrl(url); if (!target) { diff --git a/src/prs-merge.ts b/src/prs-merge.ts index a82ddf7..ef07d74 100644 --- a/src/prs-merge.ts +++ b/src/prs-merge.ts @@ -215,7 +215,16 @@ export async function sweep( /** Judge a PR believed to be settled, and merge it when it is eligible. */ const judgeAndMerge = async (pr: PullRequest, checks: Check[]): Promise => { - const reason = reasonNotMergeable(pr, checks, options.allowNoChecks); + let reason = reasonNotMergeable(pr, checks, options.allowNoChecks); + + if (reason && checks.some((c) => c.bucket === 'fail')) { + const refused = await gh.refusedToStart(pr.url, checks); + if (refused) { + reason += ` — GitHub never started ${refused.names.join(', ')}, so nothing was ` + + `tested: "${refused.message}" (account billing, not the code; run the gate ` + + 'locally and merge by hand)'; + } + } if (reason) { emit({ kind: 'skip', url: pr.url, reason, title: pr.title }); diff --git a/test/prs-merge.test.ts b/test/prs-merge.test.ts index 0e55e76..50dbe5b 100644 --- a/test/prs-merge.test.ts +++ b/test/prs-merge.test.ts @@ -29,7 +29,7 @@ interface StubStep { mergeStateStatus?: string; isDraft?: boolean; state?: string; - checks?: { name: string; bucket: string }[]; + checks?: { name: string; bucket: string; link?: string }[]; } /** @@ -50,6 +50,8 @@ function stubGh(options: { /** Workflow runs GitHub is holding for maintainer approval on the head. */ heldRuns?: { id: number; name: string; event: string }[]; approveFails?: boolean; + /** Annotation messages on every check run. */ + annotations?: string[]; }) { const calls: string[] = []; let viewIndex = 0; @@ -140,6 +142,10 @@ function stubGh(options: { ); } + if (args[0] === 'api' && args.some((a) => /\/check-runs\/\d+\/annotations$/.test(a))) { + return ok(JSON.stringify((options.annotations ?? []).map((message) => ({ message })))); + } + if (args[0] === 'api' && args.some((a) => a.endsWith('/approve'))) { return options.approveFails ? { code: 1, stdout: '', stderr: 'HTTP 403: Must have admin rights' } @@ -934,3 +940,48 @@ describe('fork CI held for maintainer approval', () => { expect(out.find((l) => l.kind === 'skip')).toMatchObject({ reason: 'no CI checks found' }); }); }); + +describe('checks GitHub never started', () => { + const JOB = 'https://github.com/acme/repo/actions/runs/39262578125/job/118093807961'; + const BILLING = + "The job was not started because recent account payments have failed or your " + + "spending limit needs to be increased. Please check the 'Billing & plans' section in your settings"; + + const run = async (annotations: string[]) => { + const { gh, calls } = stubGh({ + steps: [{ mergeStateStatus: 'UNSTABLE', checks: [{ name: 'test', bucket: 'fail', link: JOB }] }], + annotations, + }); + const lines: Line[] = []; + const summary = await sweep(baseOptions({ apply: true }), gh, (l) => lines.push(l)); + return { summary, calls, text: lines.map(render).join('\n') }; + }; + + it('says the job was refused, not that the test failed', async () => { + const { summary, calls, text } = await run([ + '"The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026."', + BILLING, + ]); + expect(summary.merged).toBe(0); + expect(summary.skipped).toBe(1); + expect(calls).toContain('api repos/acme/repo/check-runs/118093807961/annotations'); + expect(text).toContain('checks not green: test=fail — GitHub never started test'); + expect(text).toContain('recent account payments have failed'); + expect(text).toContain('not the code'); + }); + + it('leaves a real failure as a plain failure', async () => { + const { text } = await run(['Process completed with exit code 1.']); + expect(text).toContain('checks not green: test=fail — stub pr'); + expect(text).not.toContain('never started'); + }); + + it('reads the job link from gh pr checks', () => { + expect(parseChecks([{ name: 'test', bucket: 'fail', link: JOB }])).toEqual([ + { name: 'test', bucket: 'fail', link: JOB }, + ]); + expect(parseChecks([{ name: 'test', bucket: 'pass', link: '' }])).toEqual([ + { name: 'test', bucket: 'pass' }, + ]); + }); +});