mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wip(GSTA-131): reviewers stay at full effort; audits light Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end the Windows curation sentence instead of a mid-sentence colon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans 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>
341 lines
21 KiB
TypeScript
341 lines
21 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';
|
|
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'> = [
|
|
'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', 'docs', 'review_loop', 'carry',
|
|
];
|
|
|
|
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
|
|
export function orderReceipt(input: Partial<ShipReceipt> & { docs?: unknown }): ShipReceipt {
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of KEY_ORDER) if ((input as Record<string, unknown>)[key] !== undefined) out[key] = (input as Record<string, unknown>)[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)}`);
|
|
const loop = parseReviewLoop(r.review_loop);
|
|
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
|
|
+ (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : ''));
|
|
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
|
|
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// carry: the receipt follows a head whose product diff is unchanged
|
|
// ---------------------------------------------------------------------------
|
|
/**
|
|
* Paths whose change never invalidates a /ship verdict on its own: tests (the
|
|
* reviewed product code is what the verdict covers; CI at the new head reruns
|
|
* them, unreviewed) and release metadata (the version stamp, the changelog, the
|
|
* agents digest). A repo extends the lists through its ship policy
|
|
* (`stamp_files`, `stamp_paths`, `release_outputs`, `changelog`); lockfiles are
|
|
* product unless the policy names them, because a dependency change is a
|
|
* product change. The test globs name code extensions only: an API contract
|
|
* such as `openapi.spec.yaml` is product.
|
|
*/
|
|
const TEST_CODE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'rb', 'py', 'go', 'rs', 'java', 'kt', 'swift', 'dart', 'php', 'cs'];
|
|
export const CARRY_TEST_GLOBS: readonly string[] = [
|
|
'test/**', 'tests/**', 'spec/**', '__tests__/**', 'e2e/**', 'cypress/**', '**/__tests__/**',
|
|
...TEST_CODE_EXTENSIONS.flatMap(ext => [`**/*.test.${ext}`, `**/*.spec.${ext}`]),
|
|
'**/*_test.go', '**/test_*.py', '**/*_test.py', '**/*_spec.rb', '**/*Test.java', '**/*Tests.swift',
|
|
];
|
|
export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md', 'package.json', 'agents-digest/**'];
|
|
/** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */
|
|
export const CARRY_NAMED_PATHS = 20;
|
|
|
|
export type CarryClass = 'product' | 'test' | 'metadata';
|
|
export interface CarryPlanInput {
|
|
/** The reviewed head the receipt names. */
|
|
from: string; head: string;
|
|
/** Paths changed between from and head, classified. */
|
|
changed: Array<{ path: string; class: CarryClass }>;
|
|
/** Product patch-ids at both heads (empty string for no product diff). */
|
|
productPatchId: { from: string; head: string };
|
|
testPatchId: { from: string; head: string };
|
|
/** Product paths whose patch against the base differs between the two heads. */
|
|
changedProduct: string[];
|
|
mergeCommits: number;
|
|
rangeDiff: string;
|
|
}
|
|
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`) 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, 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, '\\$&');
|
|
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 ?? []));
|
|
}
|
|
|
|
/**
|
|
* One changed path's carry class. The changelog and the agents digest are
|
|
* metadata. A manifest (`package.json`, `VERSION`) is metadata only while its
|
|
* from..head change is a version stamp and nothing else (`versionStampOnly`): a
|
|
* dependency, a script or a new key is product. A path the policy names under
|
|
* `stamp_files`, `stamp_paths` or `release_outputs` is metadata only while its
|
|
* change is a stamp (`versionStampOnly` for a manifest kind, `stampOnlyText`
|
|
* otherwise): a logic change in a stamped source file is product, as it is for
|
|
* the tree receipt. `change` carries the texts; a caller without them gets the
|
|
* list-only answer.
|
|
*/
|
|
export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[] } = {}, change?: CarryChange): CarryClass {
|
|
const stamp = () => !change || (stampFileKind(rel) ? versionStampOnly(rel, change.before, change.after).stampOnly : stampOnlyText(change));
|
|
if (matchesAny(CARRY_METADATA_GLOBS, rel)) return !change || !stampFileKind(rel) || versionStampOnly(rel, change.before, change.after).stampOnly ? 'metadata' : 'product';
|
|
if (matchesAny(extra.metadata ?? [], rel)) return stamp() ? 'metadata' : 'product';
|
|
if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test';
|
|
return 'product';
|
|
}
|
|
|
|
/**
|
|
* The carry decision: the receipt follows `head` when the product diff
|
|
* against the base has the same patch-id at both heads, which is what a
|
|
* conflict-only base merge, a test-only fix or a release-metadata change
|
|
* leaves behind. A product patch that differs is RECEIPT_CARRY_PRODUCT_CHANGED,
|
|
* whatever else changed.
|
|
*/
|
|
export function planCarry(input: CarryPlanInput): CarryPlan {
|
|
if (input.productPatchId.from !== input.productPatchId.head) {
|
|
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
|
|
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
|
|
}
|
|
const reasons: string[] = [];
|
|
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
|
|
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
|
|
if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata');
|
|
if (!reasons.length) reasons.push(input.changed.length ? 'no product change' : 'no content change');
|
|
return {
|
|
ok: true,
|
|
carry: {
|
|
from: input.from, reason: reasons.join(' + '), product_patch_id: input.productPatchId.head || 'none',
|
|
changed_paths: input.changed.slice(0, 50).map(c => `${c.class}:${c.path}`), merge_commits: input.mergeCommits, range_diff: input.rangeDiff,
|
|
covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'],
|
|
reruns: ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The carried receipt: the same evidence, bound to the new head, with the carry
|
|
* recorded. A receipt carried before keeps the reviewed head as `from` and adds
|
|
* this hop's merges and paths, so the record always names the head a review saw.
|
|
*/
|
|
export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord): ShipReceipt {
|
|
const prior = receipt.carry;
|
|
const merged: CarryRecord = !prior ? carry : {
|
|
...carry, from: prior.from, reason: prior.reason === carry.reason ? carry.reason : `${prior.reason} + ${carry.reason}`,
|
|
merge_commits: prior.merge_commits + carry.merge_commits,
|
|
changed_paths: [...prior.changed_paths, ...carry.changed_paths.filter(p => !prior.changed_paths.includes(p))].slice(0, 50),
|
|
};
|
|
return orderReceipt({ ...receipt, head, carry: merged });
|
|
}
|
|
|
|
/** The loop counters the factory scorecard reads (passes, blocking_found, deferred), as `write --review-loop` records them. */
|
|
export type ReviewLoopReceipt = NonNullable<ShipReceipt['review_loop']>;
|
|
export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
const v = value as Record<string, unknown>;
|
|
const n = (k: string) => (typeof v[k] === 'number' && Number.isInteger(v[k]) && (v[k] as number) >= 0 ? (v[k] as number) : undefined);
|
|
const passes = n('passes'); const blocking = n('blocking_found'); const deferred = n('deferred');
|
|
if (passes === undefined || blocking === undefined || deferred === undefined) return undefined;
|
|
const out: ReviewLoopReceipt = { passes, blocking_found: blocking, deferred };
|
|
if (n('noted') !== undefined) out.noted = n('noted');
|
|
if (n('fixed') !== undefined) out.fixed = n('fixed');
|
|
if (typeof v.scope === 'string') out.scope = v.scope.slice(0, 20);
|
|
if (n('fix_checks') !== undefined) out.fix_checks = n('fix_checks');
|
|
if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens');
|
|
if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) {
|
|
const d = Object.fromEntries(Object.entries(v.dispatches as Record<string, unknown>)
|
|
.filter(([k, c]) => /^[a-z][a-z0-9-]{0,39}$/.test(k) && typeof c === 'number' && Number.isInteger(c) && c >= 0).slice(0, 12)) as Record<string, number>;
|
|
if (Object.keys(d).length) out.dispatches = d;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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';
|
|
}
|