mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 21:07:20 +01:00
Merge origin/main (v1.91.278.0) into eval-select-review-floor-followups; stamp v1.91.279.0
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json MERGE_CONDITION: sha=64684de144e5c5b8934408514b44434559487b18 NEW_VERSION=1.91.279.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
c7f6daa7a4
30 files changed
+654
-85
No files matched your search
@@ -193,24 +193,28 @@ export function fetchChecks(env: GhEnv, repo: string, pr: number): Map<string, C
|
||||
* Required check names for the base branch, from rulesets and classic branch
|
||||
* protection. Reading them up front means a required check that has not
|
||||
* started yet counts as pending instead of green by omission. A 404 means no
|
||||
* protection of that kind, which is normal; any other failure is logged and
|
||||
* that source contributes nothing.
|
||||
* protection of that kind, which is normal; any other failure is logged, that
|
||||
* source contributes nothing, and `known` is false: the set may be short, so a
|
||||
* caller that would relax the gate on it (`--exclude`) must not.
|
||||
*/
|
||||
export function requiredChecks(env: GhEnv, repo: string, base: string): Set<string> {
|
||||
export function requiredChecks(env: GhEnv, repo: string, base: string): { names: Set<string>; known: boolean } {
|
||||
const sources: Array<[string, string]> = [
|
||||
[`repos/${repo}/rules/branches/${base}`, '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context'],
|
||||
[`repos/${repo}/branches/${base}/protection/required_status_checks`, '.contexts[]?, .checks[]?.context'],
|
||||
];
|
||||
const names = new Set<string>();
|
||||
let known = true;
|
||||
for (const [path, jq] of sources) {
|
||||
try {
|
||||
for (const name of ghLines(env, ['api', path, '--jq', jq])) names.add(name);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/HTTP 404/.test(message)) env.log(`could not read required checks from ${path}: ${message}`);
|
||||
if (/HTTP 404/.test(message)) continue;
|
||||
known = false;
|
||||
env.log(`could not read required checks from ${path}: ${message}`);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
return { names, known };
|
||||
}
|
||||
|
||||
/** Files the PR changes, from `gh pr diff --name-only`. */
|
||||
|
||||
+52
-12
@@ -46,12 +46,22 @@ export interface PrWaitEnv {
|
||||
pidAlive(pid: number): boolean;
|
||||
}
|
||||
|
||||
export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number }
|
||||
export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number; excludes: string[] }
|
||||
export interface BotLists { gating: string[]; advisory: string[] }
|
||||
|
||||
interface State { reported: string[]; summaries: Record<string, string>; base_oid: string | null; merge_state: string | null }
|
||||
|
||||
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]
|
||||
/**
|
||||
* The base-overlap event, one shape for the watcher and for `gstack-ship-queue
|
||||
* ready` / `train build`, which read the same fact from git (handoff
|
||||
* GSTA-119): the base moved and its new commits touch files this PR changes.
|
||||
*/
|
||||
export function baseOverlapLine(base: string, baseOid: string, touched: readonly string[]): string {
|
||||
const more = touched.length > 5 ? ` and ${touched.length - 5} more` : '';
|
||||
return `${base} moved to ${baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`;
|
||||
}
|
||||
|
||||
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] [--exclude CHECK,...]
|
||||
|
||||
Watches one pull request and prints one line per real event on stdout (prefixed
|
||||
#<pr>), so a monitor wakes the agent once per event. Progress and recoverable
|
||||
@@ -69,6 +79,10 @@ comes from the URL or the current checkout.
|
||||
|
||||
--timeout minutes before a watch that met nothing ends as PR_WAIT_BLOCKED,
|
||||
naming what is still pending (default 60; 0 waits without limit)
|
||||
--exclude check names (comma-separated, repeatable) left out of the verdict,
|
||||
as gstack-ci-gate --exclude does: a named check neither holds green
|
||||
while pending nor blocks when it fails. A check the base branch
|
||||
requires cannot be excluded; the name is logged as refused and gates
|
||||
|
||||
Events: a check failed (name and link); all checks finished on the
|
||||
head (one summary line); a comment, review, thread or thread reply from someone
|
||||
@@ -101,7 +115,7 @@ lock, which the next watcher for the PR takes over once the pid is gone.
|
||||
Docs: docs/troubleshooting.md#gstack-pr-wait`;
|
||||
|
||||
export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } | { error: string } {
|
||||
const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES };
|
||||
const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES, excludes: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const flag = argv[i];
|
||||
if (flag === '--help' || flag === '-h') return { help: true };
|
||||
@@ -124,6 +138,12 @@ export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true }
|
||||
opts.timeout = Number(value);
|
||||
continue;
|
||||
}
|
||||
if (flag === '--exclude') {
|
||||
const names = (argv[++i] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!names.length) return { error: '--exclude needs one or more check names, comma-separated' };
|
||||
for (const name of names) if (!opts.excludes.includes(name)) opts.excludes.push(name);
|
||||
continue;
|
||||
}
|
||||
if (flag.startsWith('-') && !/^-?\d+$/.test(flag)) return { error: `unknown argument ${flag}` };
|
||||
if (opts.target !== undefined) return { error: `unexpected argument ${flag}` };
|
||||
opts.target = flag;
|
||||
@@ -185,12 +205,14 @@ export class Watcher {
|
||||
/** First run for this PR: record what exists and report nothing. */
|
||||
baseline: boolean;
|
||||
private required: Set<string> | null = null;
|
||||
/** False when a required-check source could not be read: the set may be short, so no --exclude name is excluded. */
|
||||
private requiredKnown = true;
|
||||
private prevSnapshot: string | null = null;
|
||||
private prFiles = new Map<string, Set<string>>();
|
||||
private pending: string[] = [];
|
||||
private readonly gh: GhEnv;
|
||||
|
||||
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES) {
|
||||
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES, readonly excludes: readonly string[] = []) {
|
||||
const stem = path.join(stateDir(env.vars), `${repo.replace('/', '__')}__${pr}`);
|
||||
this.stateFile = `${stem}.json`;
|
||||
this.lockFile = `${stem}.lock`;
|
||||
@@ -295,11 +317,22 @@ export class Watcher {
|
||||
return count === null ? '' : `; ${count} commit${count === '1' ? '' : 's'} behind ${base}`;
|
||||
}
|
||||
|
||||
private evaluateChecks(checks: Map<string, Check>, head: string) {
|
||||
/**
|
||||
* `--exclude` names that are not required checks: the ones the verdict leaves out, as gstack-ci-gate does. While the
|
||||
* required set is unknown (a source could not be read) nothing is excluded, as gstack-ci-gate refuses its overrides.
|
||||
*/
|
||||
private excluded(): Set<string> {
|
||||
return new Set(this.requiredKnown ? this.excludes.filter(name => !this.required?.has(name)) : []);
|
||||
}
|
||||
|
||||
private evaluateChecks(all: Map<string, Check>, head: string) {
|
||||
const required = this.required ?? new Set<string>();
|
||||
// Every check on the head gates, as gstack-ci-gate does, so land-and-deploy's two gates never
|
||||
// disagree; a required check gates even before it appears. An advisory bot's review is never
|
||||
// waited for, but its check is a check like any other.
|
||||
// waited for, but its check is a check like any other. A --exclude name that is not required
|
||||
// is left out of the verdict; a required one cannot be excluded and gates.
|
||||
const excluded = this.excluded();
|
||||
const checks = new Map([...all].filter(([name]) => !excluded.has(name)));
|
||||
const watched = checks;
|
||||
const gate = new Set([...required, ...watched.keys()]);
|
||||
const bucketOf = (name: string) => checks.get(name)?.bucket ?? 'pending';
|
||||
@@ -392,10 +425,7 @@ export class Watcher {
|
||||
const oldBase = this.state.base_oid;
|
||||
if (pr.baseOid && oldBase && pr.baseOid !== oldBase && !this.baseline) {
|
||||
const touched = this.baseOverlap(head, oldBase, pr.baseOid);
|
||||
if (touched.length > 0) {
|
||||
const more = touched.length > 5 ? ` and ${touched.length - 5} more` : '';
|
||||
this.emit(`base:${pr.baseOid}`, `${base} moved to ${pr.baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`);
|
||||
}
|
||||
if (touched.length > 0) this.emit(`base:${pr.baseOid}`, baseOverlapLine(base, pr.baseOid, touched));
|
||||
}
|
||||
if (pr.baseOid) this.state.base_oid = pr.baseOid;
|
||||
const mergeState = pr.mergeStateStatus;
|
||||
@@ -418,9 +448,19 @@ export class Watcher {
|
||||
if (pr.state === 'CLOSED') return this.finish(PR_WAIT_EXIT.blocked, [`closed without merging: ${pr.url}`]);
|
||||
const base = pr.baseRefName;
|
||||
if (this.required === null) {
|
||||
this.required = requiredChecks(this.gh, this.repo, base);
|
||||
const read = requiredChecks(this.gh, this.repo, base);
|
||||
this.required = read.names;
|
||||
this.requiredKnown = read.known;
|
||||
const gate = [...this.required].sort().join(', ') || 'none set';
|
||||
this.log(`watching ${this.repo}#${this.pr} until ${this.until}; required checks: ${gate}; every check on the head gates`);
|
||||
if (this.excludes.length && !this.requiredKnown) {
|
||||
this.log(`exclude refused for ${this.excludes.join(', ')}: the required checks could not be read, so every check gates`);
|
||||
} else if (this.excludes.length) {
|
||||
const refused = this.excludes.filter(name => this.required!.has(name));
|
||||
const dropped = this.excludes.filter(name => !this.required!.has(name));
|
||||
if (dropped.length) this.log(`excluded from the verdict: ${dropped.join(', ')}`);
|
||||
if (refused.length) this.log(`exclude refused for required check${refused.length === 1 ? '' : 's'} ${refused.join(', ')}: a required check gates`);
|
||||
}
|
||||
}
|
||||
const checks = fetchChecks(this.gh, this.repo, this.pr);
|
||||
const head = pr.headRefOid;
|
||||
@@ -530,7 +570,7 @@ export function prWaitMain(argv: string[], env: PrWaitEnv): number {
|
||||
if ('error' in parsed) return refuse(env, parsed.error);
|
||||
const target = resolveTarget(env, parsed);
|
||||
if ('error' in target) return refuse(env, target.error);
|
||||
const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout);
|
||||
const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout, parsed.excludes);
|
||||
// Exit 1 always carries a PR_WAIT_BLOCKED line, so a local fault (an unwritable state root, a full disk) is a refusal, never a verdict.
|
||||
try {
|
||||
const lock = watcher.lock();
|
||||
|
||||
+62
-7
@@ -128,12 +128,33 @@ function queueOrderCheck(o: RestampOptions, baseSha: string): void {
|
||||
actual[pr] = merged ? (inBase ? 'merged' : 'merged-not-in-base') : String(info.state ?? 'unknown').toLowerCase();
|
||||
}
|
||||
const stale = [...ahead.filter(pr => actual[pr] !== 'merged'), ...behind.filter(pr => actual[pr]?.startsWith('merged'))];
|
||||
if (stale.length) {
|
||||
throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}`, {
|
||||
expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha },
|
||||
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha}${mine ? ` --pr ${mine}` : ''} --expect-order ${o.expectOrder.join(',')}`,
|
||||
});
|
||||
}
|
||||
if (!stale.length) return;
|
||||
// The recompute command is the corrected order, never the stale list again: this PR first (a list that left it out read
|
||||
// every entry as a predecessor), PRs merged into the local base dropped, an unmerged predecessor kept ahead because it
|
||||
// still has to merge first. A predecessor GitHub calls merged that the local base does not carry stays ahead too: the
|
||||
// recompute pins --expect-base to that local base, and dropping the PR would let it allocate the version the
|
||||
// predecessor already took (pre-landing review, pass 1); the lane fetches the base and the entry reads merged.
|
||||
const corrected = correctedOrder(o.expectOrder, mine, actual);
|
||||
const waitingOn = ahead.filter(pr => actual[pr] !== 'merged' && actual[pr] !== 'merged-not-in-base');
|
||||
const unfetched = ahead.filter(pr => actual[pr] === 'merged-not-in-base');
|
||||
const why = mine && idx < 0 ? `; --pr ${mine} is not in --expect-order, so every listed PR read as a predecessor: list this PR first, then the remaining order`
|
||||
: [waitingOn.length ? `${waitingOn.map(pr => `#${pr}`).join(', ')} must merge before this PR stamps` : '',
|
||||
unfetched.length ? `${unfetched.map(pr => `#${pr}`).join(', ')} merged but the local ${o.base} does not carry the merge yet: git fetch origin ${o.base}, then rerun` : '']
|
||||
.filter(Boolean).map(s => `; ${s}`).join('');
|
||||
throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}${why}`, {
|
||||
expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha },
|
||||
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.slice(0, 12)}${mine ? ` --pr ${mine}` : ''} --expect-order ${corrected.join(',')}`,
|
||||
...(unfetched.length ? { fix: `git fetch origin ${o.base}` } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The --expect-order to run next: this PR first when the list left it out, every PR merged into the local base dropped,
|
||||
* the rest (open PRs and merges the local base does not carry yet) in their order.
|
||||
*/
|
||||
export function correctedOrder(order: readonly string[], mine: string | null, actual: Record<string, string>): string[] {
|
||||
const rest = order.filter(pr => actual[pr] !== 'merged');
|
||||
return mine && !order.includes(mine) ? [mine, ...rest] : rest;
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
@@ -148,7 +169,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri
|
||||
if (o.expectBase && !baseSha.out.startsWith(o.expectBase)) {
|
||||
throw new Refusal('QUEUE_STALE', `base moved: expected ${o.expectBase}, ${baseRef} is ${baseSha.out}`, {
|
||||
expected: { base: o.expectBase }, actual: { base: baseSha.out },
|
||||
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.expectOrder?.length ? ` --expect-order ${o.expectOrder.join(',')}` : ''}`,
|
||||
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.pr ? ` --pr ${o.pr}` : ''}${o.expectOrder?.length ? ` --expect-order ${correctedOrder(o.expectOrder, o.pr ?? null, {}).join(',')}` : ''}`,
|
||||
});
|
||||
}
|
||||
queueOrderCheck(o, baseSha.out);
|
||||
@@ -313,6 +334,40 @@ export function tentativeMentions(added: Map<string, Set<string>>, forkVersion:
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/** The versions one line mentions in a stamp form (the built-in forms and the policy's `stamp_patterns` templates). */
|
||||
export function stampMentions(text: string, templates: readonly string[] = []): Set<string> {
|
||||
const found = new Set<string>();
|
||||
for (const re of [...BUILT_IN_MENTIONS, ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* A removed/added line pair that a restamp alone explains: every version the
|
||||
* removed line mentions in a stamp form and the added line no longer does
|
||||
* moves to `newV`, and the rewritten line is the added line byte for byte.
|
||||
* The moved versions are the ones the stamp_paths stage moves, not only the
|
||||
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
|
||||
* minor>'` never equals the base version at the slot and is a stamp all the
|
||||
* same (#3512). The stage's own bound applies: a tentative mention is newer
|
||||
* than the version the branch was written against, so a moved version older
|
||||
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
|
||||
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
|
||||
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
|
||||
* moves nothing, or changes anything beyond those mentions, is not one either.
|
||||
*/
|
||||
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean {
|
||||
if (removed === added) return false;
|
||||
const floor = parseVersion(oldV);
|
||||
const width = versionWidth(oldV);
|
||||
const after = stampMentions(added, templates);
|
||||
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
|
||||
if (!moved.length || !floor) return false;
|
||||
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
|
||||
let text = removed;
|
||||
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
|
||||
return text === added;
|
||||
}
|
||||
|
||||
/** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */
|
||||
export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string {
|
||||
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim();
|
||||
|
||||
@@ -28,7 +28,7 @@ import { dispatchCi } from './ship-queue-dispatch-ci';
|
||||
import { recordVerdict, screenVerdict, verdictLines, type ScreenOutcome } from './ship-queue-screen';
|
||||
import { execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, rebuildFromNotes, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
import { fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { baseOverlap, fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
|
||||
@@ -181,6 +181,18 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
|
||||
const sel = await screenSelection(env, head, baseSha);
|
||||
const lines = [...base.lines, ...sel.lines];
|
||||
// gstack-pr-wait's base-overlap event, read from git at READY (handoff GSTA-119): the lane learns here that the base
|
||||
// moved onto its files, and a merge the train would stop at is refused before the pin is pooled. Under --screen the
|
||||
// scratch merge below reports the same conflict as QUEUE_SCREEN_STALE_REF. A comparison git could not make is printed as
|
||||
// unknown and refuses nothing: the screen or the train makes the merge for real.
|
||||
const overlap = baseOverlap(ctx.repoRoot, ctx.base, baseSha, head, env.policy.policy);
|
||||
if (overlap.line) lines.push(overlap.line);
|
||||
if (overlap.conflicts && o.screen !== 'screen') {
|
||||
// The fix follows the crew template (captain/templates/CREW.md rule 14): a lane merges the base into a READY PR only on
|
||||
// the controller's word, so the refusal is reported and the merge waits for that word.
|
||||
return outRefused({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${o.pr} conflicts with ${ctx.base}@${baseSha.slice(0, 12)} beyond the CHANGELOG and the stamp files (${overlap.conflicts.join(', ')}); the train would stop at this PR` },
|
||||
lines, `report the conflict to the controller; on its word merge ${ctx.base} into the pin (never rebase), resolve, push, run /ship, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
|
||||
}
|
||||
if (!loadReady(env.repo.keys, o.pr, head)) rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const prior = loadReady(env.repo.keys, o.pr, head);
|
||||
const slug = repoSlug(ctx);
|
||||
|
||||
+46
-4
@@ -18,6 +18,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import type { ResultStatus } from './headless-artifacts';
|
||||
import { baseOverlapLine } from './pr-wait';
|
||||
import { versionAtRef, versionFiles } from './restamp';
|
||||
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
|
||||
import { loadBasePolicy, type QueueContext } from './ship-queue';
|
||||
@@ -194,6 +195,39 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
|
||||
return 'error' in out ? { conflicts: [`(${out.error})`] } : { tree: out.tree };
|
||||
}
|
||||
|
||||
export interface BaseOverlap { touched: string[]; conflicts: string[] | null; unknown: boolean; line: string | null }
|
||||
|
||||
/**
|
||||
* What gstack-pr-wait's base-overlap event tells a lane, read from git at
|
||||
* READY and at train build (handoff GSTA-119): the files the base changed
|
||||
* since this head forked that the head also changes and, when `verdict` is
|
||||
* asked for, whether the train's own merge of the head onto the base is
|
||||
* clean. A base that moved onto nothing the PR touches is one line of fact; a
|
||||
* conflict is what QUEUE_TRAIN_CONFLICT would otherwise discover at the
|
||||
* scratch merge. A git read that fails is `unknown`, said so, never "no
|
||||
* overlap". Train build asks for no verdict: its own chain merges each pin
|
||||
* onto its prefix, and a merge onto the bare base would contradict it for T2+.
|
||||
*/
|
||||
export function baseOverlap(repoRoot: string, base: string, baseSha: string, head: string, policy: ShipPolicy, verdict = true): BaseOverlap {
|
||||
const fork = git(repoRoot, ['merge-base', baseSha, head]);
|
||||
const none = { touched: [] as string[], conflicts: null, unknown: false };
|
||||
if (fork.ok && fork.out === baseSha) return { ...none, line: null };
|
||||
const theirs = fork.ok && fork.out ? git(repoRoot, ['diff', '--name-only', fork.out, baseSha]) : fork;
|
||||
const ours = fork.ok && fork.out ? git(repoRoot, ['diff', '--name-only', fork.out, head]) : fork;
|
||||
if (!fork.ok || !fork.out || !theirs.ok || !ours.ok) {
|
||||
const why = [fork, theirs, ours].find(r => !r.ok)?.out.split('\n')[0] || 'git read failed';
|
||||
return { ...none, unknown: true, line: `BASE_OVERLAP: could not compare ${base}@${baseSha.slice(0, 12)} with ${head.slice(0, 12)} (${why}); conflicts unknown` };
|
||||
}
|
||||
const changed = new Set(theirs.out.split('\n').filter(Boolean));
|
||||
const touched = ours.out.split('\n').filter(f => f && changed.has(f)).sort();
|
||||
if (!touched.length) return { ...none, line: `BASE: ${base} moved to ${baseSha.slice(0, 7)} since this PR forked; its new commits touch no file this PR changes` };
|
||||
if (!verdict) return { touched, conflicts: null, unknown: false, line: `BASE_OVERLAP: ${baseOverlapLine(base, baseSha, touched)}` };
|
||||
const m = mergeStep(repoRoot, baseSha, head, policy);
|
||||
const conflicts = 'conflicts' in m ? m.conflicts : null;
|
||||
const said = conflicts ? `the train's merge onto ${base} conflicts beyond the CHANGELOG and the stamp files (${conflicts.join(', ')})` : `the train's merge onto ${base} is clean`;
|
||||
return { touched, conflicts, unknown: false, line: `BASE_OVERLAP: ${baseOverlapLine(base, baseSha, touched)}; ${said}` };
|
||||
}
|
||||
|
||||
const TRAIN_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue',
|
||||
GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
|
||||
|
||||
@@ -353,12 +387,15 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
|
||||
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
|
||||
];
|
||||
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
|
||||
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
|
||||
const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out;
|
||||
const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) }));
|
||||
const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
|
||||
if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines };
|
||||
// The scratch merges run before the fill rule (#3485): a QUEUE_TRAIN_WAITING result is also a clean dry-run merge of
|
||||
// every seated pin, and a pin the base moved onto is named (files only; the chain below is the merge verdict) before the
|
||||
// train is refused for it.
|
||||
for (const s of order.train) {
|
||||
const overlap = baseOverlap(env.ctx.repoRoot, env.ctx.base, baseSha, s.head, env.policy.policy, false);
|
||||
if (overlap.touched.length || overlap.unknown) lines.push(`#${s.pr} ${overlap.line}`);
|
||||
}
|
||||
const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy);
|
||||
if ('stoppedAt' in chain) {
|
||||
const without = order.train.filter(s => s.pr !== chain.stoppedAt);
|
||||
@@ -366,6 +403,11 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build ${prsArgs(without, !!explicit)}`],
|
||||
without.length ? `${Q} train build ${prsArgs(without, !!explicit)}` : 'none', 'none');
|
||||
}
|
||||
lines.push(`MERGE_CHECK: T1..T${order.train.length} merge clean onto ${env.ctx.base}@${baseSha.slice(0, 12)} (dry run in object storage; nothing pushed)`);
|
||||
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
|
||||
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
|
||||
const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
|
||||
if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines };
|
||||
const head = chain.prefixes.at(-1)!;
|
||||
const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
|
||||
const now = nowIso();
|
||||
|
||||
@@ -34,7 +34,9 @@ const ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
||||
* is not a `pass` claim), compared with the receipt. A leaf key shared by
|
||||
* several counters (`pass` under every gate lane, `passed` under gate and ci)
|
||||
* names any of them: the claim matches when its number equals one of those
|
||||
* counters, and a mismatch names the first of them.
|
||||
* counters, and a mismatch names the first of them. A number that is the value
|
||||
* of a `key=` or `key:` pair is that key's claim alone, never the start of a
|
||||
* `<n> <name>` claim about the next key (#3513).
|
||||
*/
|
||||
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
|
||||
const prose = body.replace(BLOCK_RE, '');
|
||||
@@ -43,12 +45,20 @@ export function checkReceiptProse(receipt: Record<string, unknown>, body: string
|
||||
const leaf = field.split('.').at(-1)!;
|
||||
byLeaf.set(leaf, [...(byLeaf.get(leaf) ?? []), { field, value }]);
|
||||
}
|
||||
const pattern = (leaf: string) => leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
|
||||
// The number-then-name branch must not start on a `key=value` or `key: value` pair's value: in `passes=2 blocking_found=1`
|
||||
// and `passes: 2 blocking_found: 1` the 2 is passes' claim, not a "2 blocking_found" one. The offsets of every counter
|
||||
// key's value are collected first, whatever the whitespace around the separator, and a claim starting there is skipped.
|
||||
const values = new Set<number>();
|
||||
for (const leaf of byLeaf.keys()) for (const m of prose.matchAll(new RegExp(`(?<![\\w-])${pattern(leaf)}\\s*[=:]\\s*(?=\\d)`, 'gi'))) values.add(m.index + m[0].length);
|
||||
const mismatches: ProseClaim[] = [];
|
||||
let checked = 0;
|
||||
for (const [leaf, counters] of byLeaf) {
|
||||
const name = leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
|
||||
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|\\b(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
|
||||
for (const m of prose.matchAll(re)) {
|
||||
const name = pattern(leaf);
|
||||
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|(?<![=:\\w.-])(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
|
||||
for (let m = re.exec(prose); m; m = re.exec(prose)) {
|
||||
// A skipped value hands the scan back right after its digits, so the key that follows still makes its own pair claim.
|
||||
if (m[2] !== undefined && values.has(m.index)) { re.lastIndex = m.index + m[2].length; continue; }
|
||||
checked++;
|
||||
const n = Number(m[1] ?? m[2]);
|
||||
if (!counters.some(c => c.value === n)) mismatches.push({ field: counters[0]!.field, prose: n, receipt: counters[0]!.value });
|
||||
|
||||
+18
-7
@@ -11,6 +11,7 @@ import { approvalSummary } from './approval-citations';
|
||||
import { renderIssueLines } from './issue-links';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
|
||||
import { stampOnlyLine } from './restamp';
|
||||
|
||||
export const RECEIPT_FENCE = 'gstack-ship-receipt';
|
||||
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
|
||||
@@ -165,22 +166,32 @@ export interface CarryPlanInput {
|
||||
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }
|
||||
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string };
|
||||
|
||||
/** The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else `package.json`). */
|
||||
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null }
|
||||
/**
|
||||
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
|
||||
* `package.json`) and the base policy's `stamp_patterns` templates.
|
||||
*/
|
||||
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] }
|
||||
|
||||
/**
|
||||
* A policy-named stamp path's change is a stamp when the new text reads as the
|
||||
* old text once every new version in it is the old one: the rule the tree receipt
|
||||
* applies to the same `stamp_paths`, `stamp_files` and `release_outputs` before
|
||||
* the train accepts a stamp commit. No version change, or a file missing on
|
||||
* either side, is not a stamp.
|
||||
* old text once every new version in it is the old one, or when every changed
|
||||
* line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old
|
||||
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule
|
||||
* the tree receipt applies to the same `stamp_paths`, `stamp_files` and
|
||||
* `release_outputs` before the train accepts a stamp commit. No version
|
||||
* change, or a file missing on either side, is not a stamp.
|
||||
*/
|
||||
export function stampOnlyText(change: CarryChange): boolean {
|
||||
const { before, after, oldVersion, newVersion } = change;
|
||||
if (before === null || after === null || !oldVersion || !newVersion || oldVersion === newVersion) return false;
|
||||
// Digit-bounded: replacing 1.2.3 inside 11.2.3 or 1.2.34 would read a different version's stamp as this one's.
|
||||
const escaped = newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before;
|
||||
if (after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before) return true;
|
||||
const was = before.split('\n');
|
||||
const now = after.split('\n');
|
||||
if (was.length !== now.length) return false;
|
||||
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
|
||||
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-4
@@ -15,7 +15,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { normalizeChangelog } from './changelog-check';
|
||||
import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle';
|
||||
import { parseUnifiedDiff, rewriteStamps, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
|
||||
import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { matchesAny, type ShipPolicy } from './ship-policy';
|
||||
|
||||
@@ -49,15 +49,18 @@ function blob(cwd: string, treeish: string, rel: string): string | null {
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
/** Every removed/added pair differs only by the exact stamp rewrites (old -> new). */
|
||||
/**
|
||||
* Every removed/added pair differs only by stamp rewrites to the new version:
|
||||
* the old version, or a tentative one (newer than it) the stamp_paths stage
|
||||
* moved (#3512), each in a stamp form (`stampOnlyLine`).
|
||||
*/
|
||||
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null {
|
||||
for (const h of hunks) {
|
||||
if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at };
|
||||
for (let i = 0; i < h.removed.length; i++) {
|
||||
const r = h.removed[i]!;
|
||||
const a = h.added[i]!;
|
||||
const rewritten = rewriteStamps(r, oldV, newV, templates);
|
||||
if (rewritten.count === 0 || rewritten.text !== a) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
|
||||
if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user