mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 11:40:51 +01:00
fix(ship-queue): key the latest run on app and name, keep undated runs standalone, quote the hint branch; restore the full CHANGELOG
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
66674ca97b
commit
1b7555ba07
4 files changed
+14457
-32
No files matched your search
+14405
-3
File diff suppressed because it is too large.
Load diff
+10
-5
@@ -191,14 +191,19 @@ dispatches the free workflows on the branch (`gh workflow run free-tests.yml
|
||||
clears the requires-remote pregate lanes with those runs
|
||||
(`gstack-pregate clear --remote <lane> --run-url <run>`) and reruns `ready`,
|
||||
which passes because the dispatched check runs attach to the commit; the
|
||||
`ci-hint:` line then says the dispatched runs count. `ready --screen` on such a
|
||||
`ci-hint:` line then says the dispatched runs count. The `ci-hint:` line is
|
||||
advisory text for the lane; nothing parses it, and a PR whose head is on a fork
|
||||
gets a hint that leaves the dispatch to the coordinator. `ready --screen` on such a
|
||||
PR is `QUEUE_SCREEN_STALE_REF` (there is no merge ref to screen), so it READYs
|
||||
with `--no-screen` after the skill's own paid cases ran focused.
|
||||
|
||||
`ci:` counts the latest run per check name, as GitHub's rollup does: a check
|
||||
that was re-run or superseded on the same commit (a cancelled run beside a later
|
||||
pass of the same name) is judged by its newest run; commit-status contexts are
|
||||
counted one each.
|
||||
`ci:` counts the latest run per check (one app's check name), as GitHub's
|
||||
rollup does: a check that was re-run or superseded on the same commit (a
|
||||
cancelled run beside a later pass of the same name from a second workflow run)
|
||||
is judged by its newest run (latest `completed_at`, then id; a run still in
|
||||
progress is newest); two apps reporting the same name stay separate; a run with
|
||||
no name or no readable `completed_at` counts on its own; commit-status contexts
|
||||
are counted one each.
|
||||
|
||||
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
|
||||
is not the PR's pushed head, CI at that exact head is not green, or the pregate
|
||||
|
||||
+24
-18
@@ -81,23 +81,28 @@ function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | '
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest run per check name, the way GitHub's rollup counts: a check that was
|
||||
* The latest run per check, the way GitHub's rollup counts: a check that was
|
||||
* re-run or superseded (a cancelled run beside a later pass of the same name on
|
||||
* the same commit) is judged by its newest run alone. Runs without a name each
|
||||
* stand on their own. Newest is the latest completed_at, then the highest id; a
|
||||
* run still in progress has no completed_at and is newer than any finished one.
|
||||
* the same commit, from a second workflow run) is judged by its newest run
|
||||
* alone. A check is one app's check name, so two apps that both report "build"
|
||||
* stay separate. Newest is the latest completed_at, then the highest id; a run
|
||||
* still in progress is newer than any finished one. A run with no name, or a
|
||||
* completed run with no readable completed_at, is never collapsed and counts on
|
||||
* its own (fail closed).
|
||||
*/
|
||||
export function latestRunPerName<T extends { name?: unknown; id?: unknown; status?: unknown; completed_at?: unknown }>(runs: T[]): T[] {
|
||||
export function latestRunPerName<T extends { name?: unknown; id?: unknown; status?: unknown; completed_at?: unknown; app?: unknown }>(runs: T[]): T[] {
|
||||
const latest = new Map<string, T>();
|
||||
const unnamed: T[] = [];
|
||||
const at = (r: T) => (r.status !== 'completed' ? Infinity : typeof r.completed_at === 'string' ? Date.parse(r.completed_at) || 0 : 0);
|
||||
const standalone: T[] = [];
|
||||
const rank = (r: T) => (r.status !== 'completed' ? Infinity : typeof r.completed_at === 'string' ? Date.parse(r.completed_at) : NaN);
|
||||
const id = (r: T) => (typeof r.id === 'number' ? r.id : Number(r.id) || 0);
|
||||
for (const r of runs) {
|
||||
if (typeof r.name !== 'string' || !r.name) { unnamed.push(r); continue; }
|
||||
const have = latest.get(r.name);
|
||||
if (!have || at(r) > at(have) || (at(r) === at(have) && id(r) > id(have))) latest.set(r.name, r);
|
||||
const app = (r.app as { id?: unknown } | null | undefined)?.id;
|
||||
if (typeof r.name !== 'string' || !r.name || Number.isNaN(rank(r))) { standalone.push(r); continue; }
|
||||
const key = `${typeof app === 'number' || typeof app === 'string' ? app : ''}\0${r.name}`;
|
||||
const have = latest.get(key);
|
||||
if (!have || rank(r) > rank(have) || (rank(r) === rank(have) && id(r) > id(have))) latest.set(key, r);
|
||||
}
|
||||
return [...latest.values(), ...unnamed];
|
||||
return [...latest.values(), ...standalone];
|
||||
}
|
||||
|
||||
/** CI at one exact SHA: the latest check run per name (as GitHub's rollup counts) and every commit-status context; unreadable or incomplete is unknown, never green. */
|
||||
@@ -123,11 +128,12 @@ function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolea
|
||||
* its head. The lane dispatches the free workflows on the branch by hand; their
|
||||
* check runs attach to the commit, so ready reads them like any other.
|
||||
*/
|
||||
function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean }, branch: string | undefined, head: string): string | null {
|
||||
function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean }, branch: string | undefined, fork: boolean, head: string): string | null {
|
||||
if (mergeable !== 'CONFLICTING') return null;
|
||||
const ref = branch ? `--ref ${branch}` : '--ref <branch>';
|
||||
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready`;
|
||||
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were dispatched on the branch, which counts`;
|
||||
const ref = branch && /^[\w./-]+$/.test(branch) ? `--ref ${branch}` : '--ref <branch>';
|
||||
if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`;
|
||||
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`;
|
||||
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were dispatched on the branch, which counts (advisory)`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -154,8 +160,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
|
||||
const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out;
|
||||
if (dirty) return fail({ code: 'QUEUE_NOT_READY', message: `uncommitted tracked changes: ${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}` }, [], run);
|
||||
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body,mergeable,headRefName']);
|
||||
let info: { headRefOid?: string; url?: string; body?: string; mergeable?: string; headRefName?: string } = {};
|
||||
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body,mergeable,headRefName,isCrossRepository']);
|
||||
let info: { headRefOid?: string; url?: string; body?: string; mergeable?: string; headRefName?: string; isCrossRepository?: boolean } = {};
|
||||
try { info = view.ok ? JSON.parse(view.out) : {}; } catch {}
|
||||
if (!info.headRefOid) return fail({ code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s head through gh (${(view.err ?? view.out).trim().slice(0, 120)})` }, [], run);
|
||||
if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run);
|
||||
@@ -177,7 +183,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
}
|
||||
const receipt = extractReceipt(info.body ?? '');
|
||||
const attempt = currentAttempt(ctx.repoRoot, o.pr);
|
||||
const hint = conflictHint(info.mergeable, ci, info.headRefName, head);
|
||||
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
|
||||
const lines = [
|
||||
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
|
||||
`pr: ${info.url ?? `#${o.pr}`}`,
|
||||
|
||||
@@ -388,7 +388,15 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
|
||||
expect(line(sameTime.r.out, 'ci: ')).toBe(`ci: red at ${sameTime.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
const unnamed = readyWith({ check_runs: [{ status: 'completed', conclusion: 'cancelled' }, { status: 'completed', conclusion: 'success' }] });
|
||||
expect(line(unnamed.r.out, 'ci: ')).toBe(`ci: red at ${unnamed.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
}, 180_000);
|
||||
const twoApps = readyWith({ check_runs: [{ ...older, conclusion: 'failure', app: { id: 1 } }, { ...newer, app: { id: 2 } }, other] });
|
||||
expect(line(twoApps.r.out, 'ci: ')).toBe(`ci: red at ${twoApps.at} (passed=2 failed=1 pending=0 skipped=0)`);
|
||||
const undated = readyWith({ check_runs: [{ id: 5, name: 'contributor-gate', status: 'completed', conclusion: 'failure' }, newer, other] });
|
||||
expect(line(undated.r.out, 'ci: ')).toBe(`ci: red at ${undated.at} (passed=2 failed=1 pending=0 skipped=0)`);
|
||||
const queued = readyWith({ check_runs: [{ ...older, conclusion: 'failure' }, { id: 6, name: 'contributor-gate', status: 'queued' }, other] });
|
||||
expect(line(queued.r.out, 'ci: ')).toBe(`ci: pending at ${queued.at} (passed=1 failed=0 pending=1 skipped=0)`);
|
||||
const nullConclusion = readyWith({ check_runs: [{ ...newer, conclusion: null }, other] });
|
||||
expect(line(nullConclusion.r.out, 'ci: ')).toBe(`ci: red at ${nullConclusion.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
}, 240_000);
|
||||
|
||||
test('a commit-status context is never collapsed with a check run of the same name', () => {
|
||||
const r = readyWith({ check_runs: [{ id: 1, name: 'deploy', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' }] }, { state: 'failure', statuses: [{ context: 'deploy', state: 'failure' }] });
|
||||
@@ -404,13 +412,13 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
|
||||
|
||||
describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts, rule 14)', () => {
|
||||
const pass = { id: 1, name: 'Free Tests', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' };
|
||||
function readyWith(mergeable: string, checks: unknown) {
|
||||
function readyWith(mergeable: string, checks: unknown, pr: Record<string, unknown> = {}) {
|
||||
const repo = fixture();
|
||||
const head = makePr(repo, 'pr-a');
|
||||
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: '', mergeable, headRefName: 'pr-a' } }, checks: { [head]: checks } });
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: '', mergeable, headRefName: 'pr-a', ...pr } }, checks: { [head]: checks } });
|
||||
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
|
||||
}
|
||||
|
||||
@@ -418,13 +426,17 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts,
|
||||
const { r, at } = readyWith('CONFLICTING', { check_runs: [] });
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`);
|
||||
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready`);
|
||||
}, 120_000);
|
||||
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`);
|
||||
const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: 'x;$(curl evil)' });
|
||||
expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>');
|
||||
const fork = readyWith('CONFLICTING', { check_runs: [] }, { isCrossRepository: true });
|
||||
expect(line(fork.r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${fork.at}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`);
|
||||
}, 240_000);
|
||||
|
||||
test('dispatched runs green at the head and a conflict: READY, with the hint saying the dispatched runs count', () => {
|
||||
const { r, at } = readyWith('CONFLICTING', { check_runs: [pass] });
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref); the runs at ${at} were dispatched on the branch, which counts`);
|
||||
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref); the runs at ${at} were dispatched on the branch, which counts (advisory)`);
|
||||
}, 120_000);
|
||||
|
||||
test('no hint without a conflict, and none while the dispatched runs are red or pending', () => {
|
||||
|
||||
Reference in new issue
Block a user