Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
58 changes: 56 additions & 2 deletions src/gh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export interface PullRequest {
export interface Check {
name: string;
bucket: Bucket;
/** The check's details page; for Actions, `.../actions/runs/<run>/job/<job>`. */
link?: string;
}

function fail(field: string, value: unknown, where: string): never {
Expand Down Expand Up @@ -163,10 +165,12 @@ export function parseChecks(raw: unknown, where = 'gh pr checks'): Check[] {

return raw.map((entry) => {
const record = entry as Record<string, unknown>;
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;
});
}

Expand Down Expand Up @@ -398,7 +402,7 @@ export class Gh {
async checks(url: string): Promise<Check[]> {
try {
return await this.json(
['pr', 'checks', url, '--json', 'bucket,name'],
['pr', 'checks', url, '--json', 'bucket,name,link'],
(raw) => parseChecks(raw),
{ allowNonZero: true },
);
Expand Down Expand Up @@ -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<string, unknown>).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<RunResult> {
const target = parsePullRequestUrl(url);
if (!target) {
Expand Down
11 changes: 10 additions & 1 deletion src/prs-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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 });
Expand Down
53 changes: 52 additions & 1 deletion test/prs-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface StubStep {
mergeStateStatus?: string;
isDraft?: boolean;
state?: string;
checks?: { name: string; bucket: string }[];
checks?: { name: string; bucket: string; link?: string }[];
}

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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' },
]);
});
});
Loading