mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
* feat(ship-queue): require a current /ship receipt before READY, train build and train merge Garry, 2026-10-25: use the gstack ship skill before merging PRs. ready printed SHIP_RECEIPT: none as information and returned ok, so the pool was READY'd without /ship. Now ready, train build (dry run included) and verify --train refuse with QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE from the PR body alone; verify accepts the receipt written for the pinned head when the current head is its stamp commit. gstack-ship-receipt result is /ship's terminal step (GSTACK_RESULT: skill=ship, run dir with the head and receipt), unattended /ship may update an existing PR's body, and INSTALL_UNVERIFIED with no verdict names the project directory to install from (GSTA-85). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship): pin Step 22 in workflow-required-steps and tighten the ship skeleton Gate edit: test/fixtures/workflow-required-steps.json pins the new "## Step 22: Terminal line (every run)" heading (strengthens: a later edit that drops the terminal line turns the required-steps test red). Steps 14, 16, 18, 20 and 22 prose tightened so the carve-guards raise shrinks to 81,600 / 1.6740 (measured skeleton 81,535, union 314,027). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json) - ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2 merge of main), as verify --train already did; a real edit stays STALE - ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is missing or merges an older head; a finished screen that planned 0 cases is never followed, and train build seats that slot unscreened (GSTA-95 on #3431) - gstack-ship-receipt result writes ship-run.json (a /ship record, not a run v1 manifest), validates --head, and prints status=incomplete on gh failures - bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines - tests for every state-matrix cell the review named; the train fixture's merge ref is a real merge commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: cycle-2 review fixes (private screen scratch, no bare-merge receipt reuse, 12-hex --head, one receipt scrub) and align the unattended hook text with the GSTACK_RESULT writers (GSTA-91) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: adversarial-review fixes (result refuses an unpushed HEAD, resolves --out and reports unwritable records; ready names a failed merge-ref fetch; unattended /ship never runs gh pr ready) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: give the fsck-rejected merge-ref spawns a timeout (spawnsync tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hollow-screen): read manifest.json by name, list artifacts by name, say when the plan is unreadable; lowercase result --head Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train build): say when the repository slug is unreadable instead of counting the screen silently Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: name an ls-remote failure in ready --screen, refuse result --pr with --body, cap echoed receipt errors, tell an unreadable install verdict from a missing one Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ls-remote refusal, --pr with --body, capped receipt errors, unreadable slug and verdict (both twins); docs name the new literals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: scrub Unicode line separators from receipt text, report an unreadable --body as incomplete, refuse unsupported verdict schemas in the bash twin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
181 lines
11 KiB
TypeScript
181 lines
11 KiB
TypeScript
/**
|
|
* ship-receipt — the handoff record, identifiers first (plan C5). Renders the
|
|
* ```gstack-ship-receipt``` block from the `ship-receipt` schema in
|
|
* lib/headless-artifacts.ts, puts it first in a PR body so truncation loses
|
|
* prose rather than identifiers, reads it back from a PR through `gh`, and
|
|
* computes the P7 adoption census over merged PRs (deduplicated by run and
|
|
* thread id against an owner-maintained thread cohort).
|
|
*/
|
|
import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts';
|
|
import { approvalSummary } from './approval-citations';
|
|
import { renderIssueLines } from './issue-links';
|
|
|
|
export const RECEIPT_FENCE = 'gstack-ship-receipt';
|
|
const KEY_ORDER: Array<keyof ShipReceipt> = [
|
|
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd',
|
|
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
|
|
'pregate', 'requires_remote', 'issues', 'approval',
|
|
];
|
|
|
|
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
|
|
export function orderReceipt(input: Partial<ShipReceipt>): ShipReceipt {
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of KEY_ORDER) if (input[key] !== undefined) out[key] = input[key];
|
|
for (const key of Object.keys(input)) if (!(key in out) && (input as any)[key] !== undefined) out[key] = (input as any)[key];
|
|
return { schema_version: 1, ...out } as ShipReceipt;
|
|
}
|
|
|
|
export function validateReceipt(receipt: unknown): ValidationError[] {
|
|
const errors: ValidationError[] = [];
|
|
checkSchema(receipt, ARTIFACT_SCHEMAS['ship-receipt'], '', errors, 'ship-receipt');
|
|
return errors;
|
|
}
|
|
|
|
export function renderReceiptBlock(receipt: ShipReceipt): string {
|
|
return '```' + RECEIPT_FENCE + '\n' + JSON.stringify(receipt, null, 2) + '\n```\n';
|
|
}
|
|
|
|
const BLOCK_RE = /```gstack-ship-receipt[ \t]*\r?\n([\s\S]*?)\r?\n```/;
|
|
|
|
/** The first receipt block in a PR body, parsed; `found: false` when absent. */
|
|
export function extractReceipt(body: string): { found: boolean; receipt?: ShipReceipt; raw?: string; errors: ValidationError[] } {
|
|
const m = BLOCK_RE.exec(body);
|
|
if (!m) return { found: false, errors: [] };
|
|
try {
|
|
const parsed = JSON.parse(m[1]!);
|
|
return { found: true, receipt: parsed, raw: m[1], errors: validateReceipt(parsed) };
|
|
} catch (e: any) {
|
|
return { found: true, raw: m[1], errors: [{ code: 'RECEIPT_INVALID', path: 'ship-receipt', message: `block is not JSON: ${e.message}` }] };
|
|
}
|
|
}
|
|
|
|
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
|
|
export type ReceiptBinding =
|
|
| { ok: true; receipt: ShipReceipt; head: string; line: string }
|
|
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
|
|
|
|
/**
|
|
* The /ship receipt a PR body must carry before READY, train build or a train
|
|
* merge (CREW rule 22; docs/ship-policy.md#ship-before-ready): a valid block
|
|
* whose `head` is one of the accepted heads. `accepted` is the head being
|
|
* READY'd, or the slot's pinned head beside the current head when the current
|
|
* head is the pin's stamp commit (the stamp changes only stamp files, so the
|
|
* receipt written for the pin stays the binding one). `line` is the
|
|
* informational `SHIP_RECEIPT:` line the READY block prints either way.
|
|
*/
|
|
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
|
|
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
|
|
const ex = extractReceipt(body);
|
|
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
|
|
if (!ex.receipt || ex.errors.length) {
|
|
const why = ex.errors[0] ? oneLine(`${ex.errors[0].message} at ${ex.errors[0].path}`).slice(0, 200) : 'invalid';
|
|
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
|
|
}
|
|
const r = ex.receipt;
|
|
const line = receiptSummaryLine(r);
|
|
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
|
|
if (!head) {
|
|
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
|
|
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line };
|
|
}
|
|
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
|
|
return { ok: true, receipt: r, head, line };
|
|
}
|
|
|
|
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
|
|
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
|
|
const block = renderReceiptBlock(receipt);
|
|
const stripped = body.replace(BLOCK_RE, '').replace(/^\s+/, '');
|
|
return block + (stripped ? '\n' + stripped : '');
|
|
}
|
|
|
|
/** The receipt's `issues` object from a gstack-issue-links --out or --json file (its other keys are preview-only). */
|
|
export function receiptIssuesFrom(file: Record<string, unknown>): ShipReceipt['issues'] {
|
|
const issues: Record<string, unknown> = {};
|
|
for (const key of ['fixes', 'refs', 'flakes', 'related']) if (file[key] !== undefined) issues[key] = file[key];
|
|
return issues as ShipReceipt['issues'];
|
|
}
|
|
|
|
/** Lines the receipt's issues imply that the body does not carry (a line may continue, e.g. `Refs #N (unreadable …)`). */
|
|
export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt['issues']>): string[] {
|
|
const have = body.split(/\r?\n/).map(l => l.trim());
|
|
const expected = renderIssueLines({ fixes: issues.fixes ?? [], refs: issues.refs ?? [], flakes: issues.flakes ?? [], cross: [], unreadable: [], ...(issues.related ? { related: issues.related } : {}) });
|
|
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
|
|
}
|
|
|
|
/** Receipt fields come from the PR body, so anything printed from one stays on its own line, control characters included. */
|
|
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f\u0080-\u009f\u2028\u2029]+/g, ' ');
|
|
|
|
/** The first lines of the completion message to the coordinator: identifiers only. */
|
|
export function receiptSummaryLine(r: ShipReceipt): string {
|
|
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
|
|
if (r.gated_tree) parts.push(`gated_tree=${r.gated_tree.slice(0, 12)}`);
|
|
if (r.tree) parts.push(`tree=${r.tree}`);
|
|
if (r.gate_reuse) parts.push(`gate-reuse=${r.gate_reuse}`);
|
|
if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`);
|
|
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
|
|
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
|
|
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
|
|
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// census (P7)
|
|
// ---------------------------------------------------------------------------
|
|
export interface CensusPr { number: number; mergedAt: string; body: string; title?: string }
|
|
export interface Census {
|
|
repo: string; since: string; merged_prs: number; with_receipt: number; invalid_receipts: number;
|
|
unattended_prs: number; consumed_prs: number; runs: string[]; threads: string[]; consumed_threads: string[];
|
|
cohort: string[] | null; cohort_consumed: number; adoption: string; durable: 'yes'; by_session_kind: Record<string, number>;
|
|
prs: Array<{ number: number; session_kind: string | null; artifacts_consumed: string | null; run: string | null; thread: string | null; valid: boolean }>;
|
|
}
|
|
|
|
function consumedCount(value: string | undefined): number {
|
|
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value ?? '');
|
|
return m ? Number(m[1]) : 0;
|
|
}
|
|
|
|
/** Merged PRs → the adoption number: receipts deduplicated by run id and thread id, against the cohort when given. */
|
|
export function computeCensus(repo: string, since: string, prs: CensusPr[], cohort: string[] | null): Census {
|
|
const runs = new Set<string>();
|
|
const threads = new Set<string>();
|
|
const consumedThreads = new Set<string>();
|
|
const byKind: Record<string, number> = {};
|
|
const rows: Census['prs'] = [];
|
|
let withReceipt = 0;
|
|
let invalid = 0;
|
|
let unattended = 0;
|
|
let consumed = 0;
|
|
const seenRuns = new Set<string>();
|
|
for (const pr of prs) {
|
|
const ex = extractReceipt(pr.body ?? '');
|
|
if (!ex.found) continue;
|
|
withReceipt += 1;
|
|
if (ex.errors.length || !ex.receipt) { invalid += 1; rows.push({ number: pr.number, session_kind: null, artifacts_consumed: null, run: null, thread: null, valid: false }); continue; }
|
|
const r = ex.receipt;
|
|
rows.push({ number: pr.number, session_kind: r.session_kind ?? null, artifacts_consumed: r.artifacts_consumed ?? null, run: r.run ?? null, thread: r.thread ?? null, valid: true });
|
|
const kind = r.session_kind ?? 'unknown';
|
|
byKind[kind] = (byKind[kind] ?? 0) + 1;
|
|
if (r.run && seenRuns.has(r.run)) continue;
|
|
if (r.run) { seenRuns.add(r.run); runs.add(r.run); }
|
|
if (r.thread) threads.add(r.thread);
|
|
if (kind === 'unattended') unattended += 1;
|
|
if (consumedCount(r.artifacts_consumed) > 0) { consumed += 1; if (r.thread) consumedThreads.add(r.thread); }
|
|
}
|
|
const cohortConsumed = cohort ? cohort.filter(t => consumedThreads.has(t)).length : consumedThreads.size;
|
|
const denominator = cohort ? cohort.length : threads.size;
|
|
return {
|
|
repo, since, merged_prs: prs.length, with_receipt: withReceipt, invalid_receipts: invalid, unattended_prs: unattended, consumed_prs: consumed,
|
|
runs: [...runs], threads: [...threads], consumed_threads: [...consumedThreads], cohort, cohort_consumed: cohortConsumed,
|
|
adoption: `${cohortConsumed}/${denominator}${cohort ? ' (cohort)' : ' (threads seen; no cohort file)'}`, durable: 'yes', by_session_kind: byKind, prs: rows,
|
|
};
|
|
}
|
|
|
|
export function renderCensus(c: Census): string {
|
|
return [
|
|
`CENSUS: repo=${c.repo} since=${c.since} merged_prs=${c.merged_prs} with_receipt=${c.with_receipt} invalid=${c.invalid_receipts} unattended=${c.unattended_prs} consumed=${c.consumed_prs} runs=${c.runs.length} threads=${c.threads.length} adoption=${c.adoption} durable=${c.durable}`,
|
|
...Object.entries(c.by_session_kind).map(([k, n]) => ` session_kind ${k}: ${n}`),
|
|
...c.prs.map(p => ` #${p.number} ${p.valid ? `session_kind=${p.session_kind ?? '-'} artifacts_consumed=${p.artifacts_consumed ?? '-'} run=${p.run ?? '-'} thread=${p.thread ?? '-'}` : 'receipt invalid'}`),
|
|
].join('\n') + '\n';
|
|
}
|