@staix/agent-hub 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
package/src/hub/routing.ts
CHANGED
|
@@ -419,7 +419,7 @@ export function assign(
|
|
|
419
419
|
namedPlanner?: PeerId;
|
|
420
420
|
/** The planner already recorded on the task (525-2): kept while it is still a holder. */
|
|
421
421
|
keptPlanner?: PeerId;
|
|
422
|
-
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
|
|
422
|
+
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean; sameFamily?: "fallback" | "never" };
|
|
423
423
|
candidates?: PeerId[];
|
|
424
424
|
notReviewer?: PeerId;
|
|
425
425
|
waitsFor?: number[];
|
|
@@ -602,8 +602,13 @@ export function assign(
|
|
|
602
602
|
trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
|
|
603
603
|
const reviewOwner = owner ?? opts.notReviewer;
|
|
604
604
|
const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
|
|
605
|
-
|
|
605
|
+
// #820: the rule needs the owner's family even when no instance is declared (builtin peers have families too).
|
|
606
|
+
const never = opts.reviewPool?.sameFamily === "never" && reviewOwner !== undefined;
|
|
607
|
+
const ownerFamily = (instanceMode || never) && reviewOwner ? (opts.adapterCatalog?.familyOf ?? familyOf)(reviewOwner, opts.instances) : undefined;
|
|
606
608
|
const sameFamily = (peer: PeerId) => ownerFamily !== undefined && (opts.adapterCatalog?.familyOf ?? familyOf)(peer, opts.instances) === ownerFamily;
|
|
609
|
+
// #820: with review.same_family = never, a reviewer of the owner's family is never chosen; the planner or the person reviews instead.
|
|
610
|
+
const neverSame = never;
|
|
611
|
+
if (neverSame) trace.push(` review.same_family = never: no reviewer of the owner's family (${ownerFamily ?? "unknown"}) is chosen; another family, the planner or the person reviews`);
|
|
607
612
|
const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
|
|
608
613
|
const named = opts.namedReviewer;
|
|
609
614
|
const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
|
|
@@ -622,21 +627,25 @@ export function assign(
|
|
|
622
627
|
const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? (opts.adapterCatalog?.familyOf ?? familyOf)(last.by, opts.instances) : undefined;
|
|
623
628
|
const pr = task.sdlc?.pr;
|
|
624
629
|
const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
|
|
625
|
-
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
|
|
630
|
+
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed, ...(neverSame ? { sameFamily: "never" as const } : {}) });
|
|
626
631
|
for (const row of reviewSelection.walk) trace.push(row.id ? ` reviewer pool candidate ${row.id} (${row.family}): ${row.reason}` : ` reviewer pool kind ${row.kind}: ${row.reason}`);
|
|
627
632
|
reviewer = reviewSelection.reviewer ?? "user";
|
|
628
633
|
if (reviewSelection.blocked) {
|
|
629
634
|
trace.push("blocked: no reviewer can run");
|
|
630
635
|
// Optional defaults retain the legacy planner escape when no fresh reviewer can run.
|
|
631
|
-
const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
636
|
+
const planners = opts.reviewPool?.required && !neverSame ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && (!neverSame || peerEntry(opts.roles, peer)?.includes("reviewer") === true) && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
632
637
|
const otherFamily = planners.filter(peer => !sameFamily(peer));
|
|
633
|
-
const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
638
|
+
const planner = pick(neverSame || otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
634
639
|
if (planner) {
|
|
635
640
|
reviewer = planner;
|
|
636
641
|
plannerFallback = true;
|
|
637
642
|
trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${planner} reviews`);
|
|
638
643
|
} else reviewBlocked = true;
|
|
639
644
|
}
|
|
645
|
+
} else if (neverSame && named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named)) {
|
|
646
|
+
const reason = `same family as ${reviewOwner}; review.same_family is never`;
|
|
647
|
+
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
648
|
+
trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
|
|
640
649
|
} else if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
|
|
641
650
|
const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
|
|
642
651
|
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
@@ -645,7 +654,9 @@ export function assign(
|
|
|
645
654
|
// #223 R1: a declared instance that reviews and does not plan goes first, busy or not.
|
|
646
655
|
const holds = (peer: PeerId, role: string) => opts.roles?.[peer]?.includes(role) === true;
|
|
647
656
|
const dedicated = (peer: PeerId) => !!opts.instances && Object.hasOwn(opts.instances, peer) && holds(peer, "reviewer") && !holds(peer, "planner");
|
|
648
|
-
|
|
657
|
+
// Under never the planner escape needs the reviewer role: a peer that plans and does not review is not a reviewer here, class listing or not.
|
|
658
|
+
const planOnly = (peer: PeerId) => neverSame && holds(peer, "planner") && !holds(peer, "reviewer");
|
|
659
|
+
const pool = independent.length || neverSame ? independent.filter(peer => !planOnly(peer)) : automatic;
|
|
649
660
|
const ready = named === undefined ? pool.filter(peer => dedicated(peer) && peer !== reviewOwner && !blocked(peer, "reviewer")) : [];
|
|
650
661
|
reviewer = pick(named === undefined ? ready.length ? ready : pool : [named], "reviewer", reviewOwner, named === undefined);
|
|
651
662
|
if (named === undefined && !ready.length && reviewer && holds(reviewer, "planner") && Object.keys(opts.roles ?? {}).some(dedicated)) {
|
|
@@ -657,6 +668,12 @@ export function assign(
|
|
|
657
668
|
sameFamilyFallback = true;
|
|
658
669
|
trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
|
|
659
670
|
}
|
|
671
|
+
// Under never an exhausted automatic choice (nobody eligible, all offline, no candidate at all) is the person's, never an approval without review.
|
|
672
|
+
if (neverSame && !reviewer && named === undefined && !reviewSelection) {
|
|
673
|
+
reviewer = "user";
|
|
674
|
+
reviewBlocked = true;
|
|
675
|
+
trace.push(" review.same_family = never: no reviewer of another family and no eligible planner; the person reviews");
|
|
676
|
+
}
|
|
660
677
|
// I-2 (#525-3): when the peers that implemented this task were the only candidates left, the person reviews. Never a fallback to an implementer.
|
|
661
678
|
if (!reviewer && [...refusals.values()].some(row => row.role === "reviewer" && row.code === "policy" && row.reason === "implemented this task")) {
|
|
662
679
|
reviewer = "user";
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { findingsForHead, findingHead, findingTally } from "./review-findings.ts";
|
|
2
|
+
import { readMergeQueue, type DeliveryTask, type MergeQueue } from "./sdlc-queue.ts";
|
|
3
|
+
import { readSdlcPr, stageDefinition, type AutoMergeMode, type PrEvidence, type SdlcConfig, type SdlcGh, type MergeRecord } from "./sdlc.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #786 T1/T2: the one guarded merge. Given a task and the head its reviewer approved, it re-reads the PR (`readSdlcPr`) and the merge queue
|
|
7
|
+
* (`readMergeQueue`) and answers either `{ action: "merge", argv }` or `{ refused: [gate, ...] }` naming every gate that fails. In `on` mode
|
|
8
|
+
* it runs exactly that argv; in `dry-run` it runs nothing. Nothing here writes the board: `Tasks.guardedMerge` owns authority, privacy and notices.
|
|
9
|
+
*
|
|
10
|
+
* The only merge method is `gh pr merge <pr> --repo <repo> --squash --match-head-commit <head>`. Never `--admin` (it bypasses branch protection),
|
|
11
|
+
* and no `--auto` while the repository does not allow auto-merge. The sole ref write is a post-MERGED CAS deletion of that PR's eligible remote head;
|
|
12
|
+
* a ref-update API never performs the merge. Off/dry-run write neither GitHub nor the board; Tasks owns on-mode host result records.
|
|
13
|
+
* `assertMergeArgv` pins that shape right before the call, so a later edit cannot widen it without failing a test.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface MergeInput {
|
|
17
|
+
/** The task as the queue sees it: its receipt (repo, PR, reviewed head), findings and dependencies. */
|
|
18
|
+
subject: DeliveryTask;
|
|
19
|
+
/** The board state now. Only `approved` work merges. */
|
|
20
|
+
state: string;
|
|
21
|
+
/** Board state of a dependency task, for "its task dependencies are approved". */
|
|
22
|
+
taskState: (id: number) => string | undefined;
|
|
23
|
+
config: SdlcConfig;
|
|
24
|
+
/** Every board task, as `Tasks.deliveryQueue` builds them: the queue links PRs to tasks through these. */
|
|
25
|
+
tasks: DeliveryTask[];
|
|
26
|
+
run: SdlcGh;
|
|
27
|
+
readableMetadata?: (path: string) => boolean;
|
|
28
|
+
/** Final synchronous authority and queue-input fence, after fresh gates and immediately before the gh call. */
|
|
29
|
+
beforeMerge?: () => string | undefined;
|
|
30
|
+
/** Post-run host fence and durable progress record. False or a throw withholds cleanup. No remote text is supplied. */
|
|
31
|
+
recordMerge?: (record: MergeRecord) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The board and the authority to act, read NOW and synchronously: `guardedMerge` calls it once, after the last PR read and in the same synchronous segment as the
|
|
34
|
+
* `gh pr merge` call (nothing is awaited between them), and refuses unless every input still equals what the decision judged. Absent (a library caller): the
|
|
35
|
+
* decision's own snapshot is all there is. `undefined` means the task is gone.
|
|
36
|
+
*/
|
|
37
|
+
fresh?: () => FreshBoard | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** What the board says at the moment of dispatch. `allowed` is the asker's authority and the hub being free to write (no recovery hold). */
|
|
40
|
+
export interface FreshBoard { subject: DeliveryTask; state: string; config: SdlcConfig; mode: AutoMergeMode; allowed: boolean }
|
|
41
|
+
export type MergeDecision = { action: "merge"; argv: string[]; repo: string; pr: number; head: string } | { refused: string[] };
|
|
42
|
+
export interface MergeOutcome {
|
|
43
|
+
mode: AutoMergeMode;
|
|
44
|
+
decision: MergeDecision;
|
|
45
|
+
/** True only when `gh pr merge` was started. A dry-run and every refusal leave it false. */
|
|
46
|
+
ran: boolean;
|
|
47
|
+
/** After a run: what GitHub reports when read back. `merged` is the only success; a nonzero exit can still end merged, so the PR is read again. */
|
|
48
|
+
result?: { exit: number; state: "merged" | "not_merged" | "unknown"; mergeCommit?: string; cleanup?: MergeRecord["cleanup"]; reason?: MergeRecord["reason"] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
52
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
53
|
+
/**
|
|
54
|
+
* Queue blockers this decision keeps: order and open-set facts, and the queue's own reading of the gates that can change between reads (checks, review requests,
|
|
55
|
+
* threads, draft). Its other blockers (identity, local gate, GitHub approval) are the lifecycle's, not this merge's, and are not repeated here.
|
|
56
|
+
*/
|
|
57
|
+
const QUEUE_BLOCKERS = [/^touching PRs require a person order$/, /^dependency /, /^artifact dependency cycle/, /^open PR /, /^PR file coverage is incomplete/,
|
|
58
|
+
/^required check .*: not successful on current head$/, /^review requests pending or unknown$/, /^review threads unresolved or unknown$/, /^PR is draft or draft status unknown$/];
|
|
59
|
+
|
|
60
|
+
/** The mode that applies to one repository: its own table first, then the top-level key, default off. */
|
|
61
|
+
export function mergeModeFor(config: SdlcConfig, repo: string | undefined): AutoMergeMode {
|
|
62
|
+
return (repo ? config.repos?.[repo.toLowerCase()]?.autoMerge : undefined) ?? config.autoMerge ?? "off";
|
|
63
|
+
}
|
|
64
|
+
/** Required checks of one repository: its table first, then the top-level `required_checks`, each name once. */
|
|
65
|
+
export function requiredChecksFor(config: SdlcConfig, repo: string | undefined): string[] {
|
|
66
|
+
return [...new Set([...(repo ? config.repos?.[repo.toLowerCase()]?.requiredChecks ?? [] : []), ...config.requiredChecks])];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The exact argv `on` mode runs, or an error. A closed shape: anything else is refused before it reaches gh. */
|
|
70
|
+
export function mergeArgv(repo: string, pr: number, head: string): string[] {
|
|
71
|
+
if (!REPO.test(repo) || repo.startsWith("-") || !Number.isSafeInteger(pr) || pr <= 0 || !SHA.test(head)) throw new Error("merge argv needs owner/repository, a PR number and a full head");
|
|
72
|
+
return ["pr", "merge", String(pr), "--repo", repo, "--squash", "--match-head-commit", head];
|
|
73
|
+
}
|
|
74
|
+
export function assertMergeArgv(argv: readonly string[], head: string): void {
|
|
75
|
+
const [verb, sub, pr, flag, repo, ...rest] = argv;
|
|
76
|
+
const shape = verb === "pr" && sub === "merge" && /^[1-9][0-9]*$/.test(pr ?? "") && flag === "--repo" && REPO.test(repo ?? "") && !repo!.startsWith("-")
|
|
77
|
+
&& rest.length === 3 && rest[0] === "--squash" && rest[1] === "--match-head-commit" && rest[2] === head && SHA.test(head);
|
|
78
|
+
if (!shape) throw new Error("refusing a merge argv outside the guarded shape");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkGates(evidence: PrEvidence, required: readonly string[]): string[] {
|
|
82
|
+
if (!required.length) return ["required checks unknown: name them in sdlc.toml"];
|
|
83
|
+
const gates: string[] = [];
|
|
84
|
+
for (const name of required) {
|
|
85
|
+
const matches = evidence.checks.filter(check => check.name === name);
|
|
86
|
+
const state = !matches.length ? "missing" : matches.some(c => c.state === "failed") ? "failing" : matches.some(c => c.state === "pending") ? "pending" : matches.some(c => c.state !== "passed") ? "unknown" : undefined;
|
|
87
|
+
if (state) gates.push(`required check ${name}: ${state}`);
|
|
88
|
+
}
|
|
89
|
+
return gates;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function queueGates(queue: MergeQueue | undefined, pr: number): string[] {
|
|
93
|
+
const row = queue?.rows.find(r => r.pr === pr);
|
|
94
|
+
if (!queue || !row) return ["merge queue unavailable: this PR is not in the open snapshot"];
|
|
95
|
+
const gates: string[] = row.blockers.filter(text => QUEUE_BLOCKERS.some(re => re.test(text))).map(text => `queue: ${text}`);
|
|
96
|
+
const place = queue.order.indexOf(pr);
|
|
97
|
+
for (const conflict of queue.conflicts) {
|
|
98
|
+
if (conflict.left !== pr && conflict.right !== pr) continue;
|
|
99
|
+
const other = conflict.left === pr ? conflict.right : conflict.left, before = queue.order.indexOf(other);
|
|
100
|
+
if (before !== -1 && place !== -1 && before < place) gates.push(`not first among touching PRs: #${other} merges first`);
|
|
101
|
+
}
|
|
102
|
+
if (queue.unordered?.includes(pr) && !gates.length) gates.push("queue: touching PR order is unresolved");
|
|
103
|
+
return [...new Set(gates)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A queue blocker that only repeats a gate the evidence already named adds nothing: drop it, and keep the ones that say something new (a disagreement between reads). */
|
|
107
|
+
function dropEchoes(gates: string[]): string[] {
|
|
108
|
+
const named = (re: RegExp) => gates.some(g => !g.startsWith("queue: ") && re.test(g));
|
|
109
|
+
return gates.filter(gate => {
|
|
110
|
+
if (!gate.startsWith("queue: ")) return true;
|
|
111
|
+
const check = /^queue: required check (.*): not successful on current head$/.exec(gate);
|
|
112
|
+
if (check) return !named(new RegExp(`^required check ${check[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: `));
|
|
113
|
+
if (gate === "queue: review requests pending or unknown") return !named(/review requests? (unknown|pending)|^\d+ review requests? pending$/);
|
|
114
|
+
if (gate === "queue: review threads unresolved or unknown") return !named(/review threads unknown|unresolved review thread/);
|
|
115
|
+
if (gate === "queue: PR is draft or draft status unknown") return !named(/^draft /);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The gates one reading of the PR can fail. Run on every reading this decision takes, so a gate that changes between reads is never judged on the older one. */
|
|
121
|
+
function evidenceGates(evidence: PrEvidence, approved: string | undefined, required: readonly string[]): string[] {
|
|
122
|
+
const gates: string[] = [];
|
|
123
|
+
if (approved && evidence.head !== approved) gates.push(`head changed: reviewed ${approved.slice(0, 8)}, PR is at ${evidence.head.slice(0, 8)}`);
|
|
124
|
+
if (evidence.state !== "OPEN") gates.push(`PR is ${evidence.state.toLowerCase()}`);
|
|
125
|
+
if (evidence.draft !== false) gates.push("draft (or draft status unknown)");
|
|
126
|
+
gates.push(...checkGates(evidence, required));
|
|
127
|
+
if (evidence.review === "changes_requested") gates.push("change request pending");
|
|
128
|
+
if (evidence.pendingReviews === null) gates.push("review requests unknown");
|
|
129
|
+
else if (evidence.pendingReviews > 0) gates.push(`${evidence.pendingReviews} review request${evidence.pendingReviews === 1 ? "" : "s"} pending`);
|
|
130
|
+
if (evidence.unresolved === null) gates.push("review threads unknown");
|
|
131
|
+
else if (evidence.unresolved > 0) gates.push(`${evidence.unresolved} unresolved review thread${evidence.unresolved === 1 ? "" : "s"}`);
|
|
132
|
+
// A release goes out with the person's consent, whatever the other gates say (#784 release consent).
|
|
133
|
+
if (/^release[/-]/i.test(evidence.headBranch ?? "")) gates.push("release PR: person consent required");
|
|
134
|
+
return gates;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The gates that come from the board, not from GitHub: privacy, approval, task dependencies, open Important findings on this head and a release stage. Pure and
|
|
139
|
+
* synchronous on purpose: `decideMerge` runs it on its starting snapshot and `guardedMerge` runs it again on a fresh board read right before `gh pr merge`.
|
|
140
|
+
*/
|
|
141
|
+
export function localGates(input: { subject: DeliveryTask; state: string; taskState: (id: number) => string | undefined; config: SdlcConfig; head: string | undefined }): string[] {
|
|
142
|
+
const { subject, config } = input, gates: string[] = [];
|
|
143
|
+
if (subject.private) return ["private task: a person merges"];
|
|
144
|
+
if (input.state !== "approved") gates.push(`task not approved (state ${input.state})`);
|
|
145
|
+
for (const id of subject.after) {
|
|
146
|
+
const state = input.taskState(id);
|
|
147
|
+
if (state !== "approved") gates.push(`dependency task #${id} is not approved (${state ?? "unknown"})`);
|
|
148
|
+
}
|
|
149
|
+
if (findingTally(findingsForHead(subject.findings ?? [], findingHead(subject.receipt, input.head ? { head: input.head } : undefined))).openImportant > 0) gates.push("open Important finding");
|
|
150
|
+
if (subject.receipt && stageDefinition(config, subject.receipt.stage)?.base === "release") gates.push("release PR: person consent required");
|
|
151
|
+
return gates;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every failing gate, never the first only: a person reading a refusal fixes them in one pass. Unknown evidence is a failing gate, never a pass.
|
|
156
|
+
* `approvedHead` is the head the reviewer's verdict recorded (`receipt.reviewedHead`); a head that moved since is `head changed`.
|
|
157
|
+
*/
|
|
158
|
+
export async function decideMerge(input: MergeInput): Promise<MergeDecision> {
|
|
159
|
+
const { subject, config, run } = input, receipt = subject.receipt, repo = receipt?.repo, pr = receipt?.pr, approved = receipt?.reviewedHead;
|
|
160
|
+
const gates: string[] = [];
|
|
161
|
+
if (subject.private) return { refused: ["private task: a person merges"] };
|
|
162
|
+
if (!repo || !pr) return { refused: [...gates, "PR identity missing"] };
|
|
163
|
+
if (!approved || !SHA.test(approved)) gates.push("reviewed head not recorded: the approving verdict must name the head");
|
|
164
|
+
const evidence = await readSdlcPr(receipt, run);
|
|
165
|
+
if (!evidence) return { refused: [...gates, "PR evidence unavailable"] };
|
|
166
|
+
const required = requiredChecksFor(config, repo);
|
|
167
|
+
gates.push(...evidenceGates(evidence, approved, required));
|
|
168
|
+
gates.push(...localGates({ subject, state: input.state, taskState: input.taskState, config, head: evidence.head }));
|
|
169
|
+
let queue: MergeQueue | undefined;
|
|
170
|
+
try { queue = await readMergeQueue(repo, config, input.tasks, run, input.readableMetadata); } catch { queue = undefined; }
|
|
171
|
+
gates.push(...queueGates(queue, pr));
|
|
172
|
+
// The queue read newer evidence than the first read. Read the PR once more, last, and judge every gate on it too: a check, review request, thread, draft or
|
|
173
|
+
// head that moved between the reads refuses here. `--match-head-commit` pins the head only, never these gates.
|
|
174
|
+
const last = await readSdlcPr(receipt, run);
|
|
175
|
+
if (!last) gates.push("PR evidence unavailable on the final read");
|
|
176
|
+
else { gates.push(...evidenceGates(last, approved, required)); gates.push(...localGates({ subject, state: input.state, taskState: input.taskState, config, head: last.head })); }
|
|
177
|
+
const row = queue?.rows.find(r => r.pr === pr);
|
|
178
|
+
if (row?.head !== undefined && approved && row.head !== approved && !gates.some(g => g.startsWith("head changed"))) gates.push(`head changed: the queue read ${row.head.slice(0, 8)}`);
|
|
179
|
+
if (gates.length) return { refused: dropEchoes([...new Set(gates)]) };
|
|
180
|
+
return { action: "merge", argv: mergeArgv(repo, pr, approved!), repo, pr, head: approved! };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Every board-side input the decision rested on, compared with the board as it is at dispatch. Pure and synchronous. The decision's inputs are: the task's state, repo,
|
|
185
|
+
* PR and reviewed head, its privacy, its dependencies (the edges and each one's state), its open Important findings for the head, the merge mode and required checks in
|
|
186
|
+
* force, and the asker's authority. Any difference refuses: a gate checked before an await is never trusted after it.
|
|
187
|
+
*/
|
|
188
|
+
export function dispatchGates(input: Pick<MergeInput, "subject" | "taskState"> & { config: SdlcConfig; mode: AutoMergeMode }, decision: { repo: string; pr: number; head: string }, now: FreshBoard | undefined): string[] {
|
|
189
|
+
const moved = ["task changed during the merge check: nothing was run"];
|
|
190
|
+
if (!now) return moved;
|
|
191
|
+
const receipt = now.subject.receipt;
|
|
192
|
+
if (receipt?.repo !== decision.repo || receipt?.pr !== decision.pr || receipt?.reviewedHead !== decision.head) return moved;
|
|
193
|
+
if (now.mode !== input.mode) return ["merge mode changed during the merge check: nothing was run"];
|
|
194
|
+
if (JSON.stringify(requiredChecksFor(now.config, decision.repo)) !== JSON.stringify(requiredChecksFor(input.config, decision.repo))) return ["required checks changed during the merge check: nothing was run"];
|
|
195
|
+
if (!now.allowed) return ["authority or hub availability changed during the merge check: nothing was run"];
|
|
196
|
+
if (JSON.stringify([...now.subject.after].sort((x, y) => x - y)) !== JSON.stringify([...input.subject.after].sort((x, y) => x - y))) return ["task dependencies changed during the merge check: nothing was run"];
|
|
197
|
+
return localGates({ subject: now.subject, state: now.state, taskState: input.taskState, config: now.config, head: decision.head });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Decide, then act by mode: `on` runs the one argv and reads the PR back, `dry-run` reports the same decision and runs nothing, `off` reads nothing.
|
|
202
|
+
* The dispatch segment below has no await: the board is read (`fresh`), every input is compared with the decision, and `run` is called, all in one synchronous
|
|
203
|
+
* stretch that starts after the last PR read.
|
|
204
|
+
*/
|
|
205
|
+
export async function guardedMerge(input: MergeInput & { mode: AutoMergeMode }): Promise<MergeOutcome> {
|
|
206
|
+
if (input.mode === "off") return { mode: "off", decision: { refused: ["auto_merge is off for this repository"] }, ran: false };
|
|
207
|
+
const decision = await decideMerge(input);
|
|
208
|
+
if (!("action" in decision) || input.mode !== "on") return { mode: input.mode, decision, ran: false };
|
|
209
|
+
// ---- synchronous dispatch segment: no await from here to the call to `run` ----
|
|
210
|
+
assertMergeArgv(decision.argv, decision.head);
|
|
211
|
+
if (input.fresh) {
|
|
212
|
+
const refused = dispatchGates(input, decision, input.fresh());
|
|
213
|
+
if (refused.length) return { mode: input.mode, decision: { refused }, ran: false };
|
|
214
|
+
}
|
|
215
|
+
let started: Promise<{ code: number; stdout: string }>;
|
|
216
|
+
const moved = input.beforeMerge?.();
|
|
217
|
+
if (moved) return { mode: input.mode, decision: { refused: [moved] }, ran: false };
|
|
218
|
+
try { started = Promise.resolve(input.run(decision.argv, 10_000)); } catch { started = Promise.resolve({ code: 1, stdout: "" }); }
|
|
219
|
+
// ---- end of the synchronous segment ----
|
|
220
|
+
let exit = 1;
|
|
221
|
+
try { exit = (await started).code; } catch { exit = 1; }
|
|
222
|
+
// A transport failure can still have merged. Bound lag readback within this one invocation; never repeat the merge.
|
|
223
|
+
const deadline = performance.now() + 6000;
|
|
224
|
+
const bounded: SdlcGh = (args, timeout, cwd, cap) => {
|
|
225
|
+
const left = deadline - performance.now();
|
|
226
|
+
return left > 0 ? input.run(args, Math.min(timeout ?? 2000, left), cwd, Math.min(cap ?? 65536, 65536)) : Promise.resolve({ code: 1, stdout: "" });
|
|
227
|
+
};
|
|
228
|
+
let after: PrEvidence | undefined;
|
|
229
|
+
for (let attempt = 0; attempt < 3 && performance.now() < deadline; attempt++) {
|
|
230
|
+
const observed = await readSdlcPr({ stage: input.subject.receipt!.stage, repo: decision.repo, pr: decision.pr }, bounded).catch(() => undefined);
|
|
231
|
+
// MERGED is terminal evidence. Retry for its commit without losing that fact to a stale or failed read.
|
|
232
|
+
if (observed && (after?.state !== "MERGED" || observed.state === "MERGED" && observed.mergeCommit)) after = observed;
|
|
233
|
+
if (after?.state === "MERGED" && after.mergeCommit || after?.state === "CLOSED") break;
|
|
234
|
+
const left = deadline - performance.now();
|
|
235
|
+
if (attempt < 2 && left > 0) await Bun.sleep(Math.min(100, left));
|
|
236
|
+
}
|
|
237
|
+
const result: NonNullable<MergeOutcome["result"]> = after?.state === "MERGED" ? {exit,state:"merged",...(after.mergeCommit?{mergeCommit:after.mergeCommit}:{})} : after ? {exit,state:"not_merged"} : {exit,state:"unknown"};
|
|
238
|
+
if (input.recordMerge) {
|
|
239
|
+
let record: MergeRecord={repo:decision.repo,pr:decision.pr,head:decision.head,at:Date.now(),...result,cleanup:"not_started",ownerNotice:"not_started"};
|
|
240
|
+
const save = () => {try {return input.recordMerge!({...record});}catch{return false;}};
|
|
241
|
+
if (!save()) {record={...record,cleanup:"skipped",reason:"authority_changed"};save();}
|
|
242
|
+
else if (result.state!=="merged" || !result.mergeCommit) {record={...record,cleanup:"skipped",reason:result.state==="merged"?"missing_commit":"not_merged"};save();}
|
|
243
|
+
else {
|
|
244
|
+
const cleanup=await cleanupMergedBranch(decision,result.mergeCommit,bounded,progress=>{record={...record,...progress};return save();});
|
|
245
|
+
record={...record,...cleanup};save();
|
|
246
|
+
}
|
|
247
|
+
result.cleanup=record.cleanup;
|
|
248
|
+
if(record.reason)result.reason=record.reason;
|
|
249
|
+
}
|
|
250
|
+
return { mode: "on", decision, ran: true, result };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Only the merged PR's exact same-repository, unprotected nondefault head can be deleted. No local git command is used. */
|
|
254
|
+
async function cleanupMergedBranch(decision:{repo:string;pr:number;head:string},mergeCommit:string,run:SdlcGh,claim:(progress:Pick<MergeRecord,"cleanup"> & Partial<Pick<MergeRecord,"reason">>)=>boolean):Promise<Pick<MergeRecord,"cleanup"> & Partial<Pick<MergeRecord,"reason">>> {
|
|
255
|
+
const skip=(reason:NonNullable<MergeRecord["reason"]>)=>({cleanup:"skipped" as const,reason});
|
|
256
|
+
const read=async(args:string[])=>{try{const r=await run(args);if(r.code!==0)return;const data=JSON.parse(r.stdout);return data?.errors?undefined:data;}catch{return;}};
|
|
257
|
+
const [owner,name]=decision.repo.split("/");
|
|
258
|
+
const query="query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){id nameWithOwner defaultBranchRef{name} pullRequest(number:$pr){state headRefName headRefOid headRepository{nameWithOwner} mergeCommit{oid}}}}";
|
|
259
|
+
const args=["api","graphql","-f",`query=${query}`,"-f",`owner=${owner}`,"-f",`name=${name}`,"-F",`pr=${decision.pr}`];
|
|
260
|
+
const initial=(await read(args))?.data?.repository,p=initial?.pullRequest;
|
|
261
|
+
if (!initial) return skip("metadata_unknown");
|
|
262
|
+
if (!initial || p?.state!=="MERGED" || p.mergeCommit?.oid!==mergeCommit || p.headRefOid!==decision.head || p.headRepository?.nameWithOwner?.toLowerCase()!==decision.repo.toLowerCase()) return skip("identity_changed");
|
|
263
|
+
const branch=p.headRefName;
|
|
264
|
+
// ponytail: cleanup accepts only this ASCII 200-character ref subset; use a shared validated Git ref-name parser before widening it. Unknown spelling never reaches a mutation.
|
|
265
|
+
if(typeof branch!=="string" || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(branch) || branch.includes("..") || branch.includes("//") || branch.endsWith("/") || branch.endsWith(".") || branch.endsWith(".lock"))return skip("metadata_unknown");
|
|
266
|
+
if(typeof initial.defaultBranchRef?.name!=="string")return skip("metadata_unknown");
|
|
267
|
+
if(branch===initial.defaultBranchRef.name)return skip("default_branch");
|
|
268
|
+
const lastQuery=query.replace("mergeCommit{oid}}}}","mergeCommit{oid}} ref(qualifiedName:$ref){name prefix target{oid} branchProtectionRule{id}}}}").replace("$pr:Int!","$pr:Int!,$ref:String!");
|
|
269
|
+
const current=(await read(["api","graphql","-f",`query=${lastQuery}`,"-f",`owner=${owner}`,"-f",`name=${name}`,"-F",`pr=${decision.pr}`,"-f",`ref=refs/heads/${branch}`]))?.data?.repository;
|
|
270
|
+
const now=current?.pullRequest;
|
|
271
|
+
if (!current) return skip("metadata_unknown");
|
|
272
|
+
if(typeof current.id!=="string" || !current.id || current.nameWithOwner?.toLowerCase()!==decision.repo.toLowerCase() || now?.state!=="MERGED" || now.mergeCommit?.oid!==mergeCommit || now.headRefName!==branch || now.headRefOid!==decision.head || now.headRepository?.nameWithOwner?.toLowerCase()!==decision.repo.toLowerCase())return skip("identity_changed");
|
|
273
|
+
if(typeof current.defaultBranchRef?.name!=="string")return skip("metadata_unknown");
|
|
274
|
+
if(current.defaultBranchRef.name===branch)return skip("default_branch");
|
|
275
|
+
if(!Object.hasOwn(current,"ref"))return skip("metadata_unknown");
|
|
276
|
+
if(current.ref===null)return {cleanup:"already_gone"};
|
|
277
|
+
if(current.ref?.name!==branch || current.ref?.prefix!=="refs/heads/" || !Object.hasOwn(current.ref,"branchProtectionRule"))return skip("metadata_unknown");
|
|
278
|
+
if(current.ref.branchProtectionRule!==null)return skip("protected_branch");
|
|
279
|
+
if(current.ref.target?.oid!==decision.head)return skip("head_changed");
|
|
280
|
+
// This endpoint includes all active repository/organization rulesets; any active rule conservatively protects the branch.
|
|
281
|
+
const rules=await read(["api",`repos/${decision.repo}/rules/branches/${encodeURIComponent(branch)}?per_page=100`]);
|
|
282
|
+
if(!Array.isArray(rules))return skip("metadata_unknown");
|
|
283
|
+
if(rules.length)return skip("protected_branch");
|
|
284
|
+
// Persist the claim after every await and immediately before the sole ref write. A lost acknowledgement is never retried.
|
|
285
|
+
if(!claim({cleanup:"claimed"}))return skip("authority_changed");
|
|
286
|
+
const mutation='mutation($repo:ID!,$ref:GitRefname!,$before:GitObjectID!,$after:GitObjectID!){updateRefs(input:{repositoryId:$repo,clientMutationId:"agent-hub-post-merge-cleanup",refUpdates:[{name:$ref,beforeOid:$before,afterOid:$after}]}){clientMutationId}}';
|
|
287
|
+
const reply=await read(["api","graphql","-f",`query=${mutation}`,"-f",`repo=${current.id}`,"-f",`ref=refs/heads/${branch}`,"-f",`before=${decision.head}`,"-f",`after=${"0".repeat(40)}`]);
|
|
288
|
+
if(reply?.data?.updateRefs?.clientMutationId==="agent-hub-post-merge-cleanup")return {cleanup:"deleted"};
|
|
289
|
+
const recheck=(await read(["api","graphql","-f",`query=${lastQuery}`,"-f",`owner=${owner}`,"-f",`name=${name}`,"-F",`pr=${decision.pr}`,"-f",`ref=refs/heads/${branch}`]))?.data?.repository;
|
|
290
|
+
if(recheck?.nameWithOwner?.toLowerCase()===decision.repo.toLowerCase() && recheck.ref?.name===branch && recheck.ref?.prefix==="refs/heads/" && typeof recheck.ref.target?.oid==="string" && SHA.test(recheck.ref.target.oid) && recheck.ref.target.oid!==decision.head)return skip("head_changed");
|
|
291
|
+
return {cleanup:"unknown",reason:"delete_unconfirmed"};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** What a caller (the control op, the CLI, a test) sees: the decision and, after a run, GitHub's own readback. No remote text, no titles. */
|
|
295
|
+
export interface MergeView { mode: AutoMergeMode; ran: boolean; decision: "merge" | "refused"; argv?: string[]; refused?: string[]; result?: MergeOutcome["result"] }
|
|
296
|
+
export function mergeView(outcome: MergeOutcome): MergeView {
|
|
297
|
+
const { decision } = outcome;
|
|
298
|
+
return { mode: outcome.mode, ran: outcome.ran, ...("action" in decision ? { decision: "merge" as const, argv: decision.argv } : { decision: "refused" as const, refused: decision.refused }), ...(outcome.result ? { result: outcome.result } : {}) };
|
|
299
|
+
}
|
package/src/hub/sdlc-metrics.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type BandTier = typeof BAND_TIERS[number];
|
|
|
11
11
|
export interface MetricTask {
|
|
12
12
|
id: number;
|
|
13
13
|
private?: boolean;
|
|
14
|
-
/** `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
-
history: { at: number; event: string; requestHead?: string; verdictHead?: string }[];
|
|
14
|
+
/** `evidenceOnly` marks a code-scope verdict with no open finding (#852): it is not a rework round. `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
+
history: { at: number; event: string; requestHead?: string; verdictHead?: string; evidenceOnly?: true }[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Per-task values; `null` is unknown. */
|
|
@@ -98,6 +98,8 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
98
98
|
const spec = first(view, ["spec accepted"]);
|
|
99
99
|
const plan = spec ? first(view, ["plan reviewed"], spec.at) : undefined;
|
|
100
100
|
const verdict = accepted ? first(view, VERDICTS, accepted.at) : first(view, VERDICTS);
|
|
101
|
+
// Evidence-only still ends the actual wait for a first review, but says nothing about whether the work passed.
|
|
102
|
+
const substantiveVerdict = history.find(entry => entry.at >= (accepted?.at ?? -Infinity) && VERDICTS.includes(entry.event) && entry.evidenceOnly !== true);
|
|
101
103
|
const dones = history.filter(entry => entry.event === "done");
|
|
102
104
|
const ci = first(view, ["check passed", "check failed"]);
|
|
103
105
|
const approvedAt = history.find(entry => entry.event === "approved")?.at ?? null;
|
|
@@ -108,10 +110,10 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
108
110
|
acceptance_to_plan_ms: spec && plan ? plan.at - spec.at : null,
|
|
109
111
|
acceptance_to_first_review_ms: accepted && verdict ? verdict.at - accepted.at : null,
|
|
110
112
|
acceptance_to_merge_ms: accepted && extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) && extra.mergedAt >= accepted.at ? extra.mergedAt - accepted.at : null,
|
|
111
|
-
first_pass_approval:
|
|
113
|
+
first_pass_approval: substantiveVerdict ? (substantiveVerdict.event === "approved" ? 1 : 0) : null,
|
|
112
114
|
// Rework grows with every round, so it is a value only once the task is approved: a task still in flight has no final one, and a
|
|
113
115
|
// provisional point would move at each verdict and could start a second breach episode inside one shift.
|
|
114
|
-
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested").length : null,
|
|
116
|
+
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested" && entry.evidenceOnly !== true).length : null,
|
|
115
117
|
first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
|
|
116
118
|
same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
|
|
117
119
|
stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
import type { SdlcGh } from "./sdlc.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #786 T3 (AC1): mirror a hub verdict as ONE `COMMENT` review on the head it was given for. One GitHub account authors and merges every PR, and GitHub refuses `APPROVE` and
|
|
6
|
+
* `REQUEST_CHANGES` on your own PR, so a comment is the only review the hub can leave. It is a record for a person reading the PR, never a gate: nothing here reads it back
|
|
7
|
+
* as approval, and a failure never touches the verdict (the caller runs this after the verdict is saved and never awaits it).
|
|
8
|
+
*
|
|
9
|
+
* The call is exactly `gh api repos/<repo>/pulls/<pr>/reviews -f event=COMMENT -f commit_id=<head> -f body=<text>` (`-f` makes it a POST), checked by `assertMirrorArgv` right before
|
|
10
|
+
* it runs. The first line of the body is `agent-hub verdict: approved|changes_requested (task #N, reviewer <peer>)`; the rest is the note's public view, which the caller supplies
|
|
11
|
+
* only when the task is not private and the note was not withheld (a PII task mirrors the first line alone).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type MirrorMode = "off" | "dry-run" | "on";
|
|
15
|
+
export type MirrorVerdict = "approved" | "changes_requested";
|
|
16
|
+
export interface MirrorInput {
|
|
17
|
+
repo: string; pr: number;
|
|
18
|
+
/** The head the verdict was given for. A PR that is no longer at it gets nothing. */
|
|
19
|
+
head: string;
|
|
20
|
+
verdict: MirrorVerdict;
|
|
21
|
+
task: number;
|
|
22
|
+
reviewer: string;
|
|
23
|
+
/** The note's public view, already screened by the caller. Absent for a private task or a withheld note. */
|
|
24
|
+
note?: string;
|
|
25
|
+
mode: MirrorMode;
|
|
26
|
+
run: SdlcGh;
|
|
27
|
+
}
|
|
28
|
+
export type MirrorOutcome =
|
|
29
|
+
| { mode: MirrorMode; posted: false; reason: "off" | "stale head" | "pr not open" | "already mirrored" | "unreadable" | "in flight" | "invalid" }
|
|
30
|
+
| { mode: "dry-run"; posted: false; reason: "dry-run"; argv: string[] }
|
|
31
|
+
| { mode: "on"; posted: boolean; reason: "posted" | "post failed"; argv: string[] };
|
|
32
|
+
|
|
33
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
34
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
35
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
36
|
+
/** GitHub's review body limit is far larger; a mirror is a pointer, so it stays short. */
|
|
37
|
+
export const MIRROR_NOTE_MAX = 3000;
|
|
38
|
+
const MARK = "agent-hub verdict: ";
|
|
39
|
+
|
|
40
|
+
/** The first line the mirror starts with. The retry check matches its stable part (verdict and task), so a different reviewer's repeat of the same verdict is not posted twice. */
|
|
41
|
+
export const mirrorHeadline = (verdict: MirrorVerdict, task: number, reviewer: string): string => `${MARK}${verdict} (task #${task}, reviewer ${reviewer})`;
|
|
42
|
+
const stableHeadline = (verdict: MirrorVerdict, task: number): string => `${MARK}${verdict} (task #${task}, reviewer `;
|
|
43
|
+
|
|
44
|
+
/** The whole body: the headline, then the note when there is one. The note cannot forge a headline of its own (`sanitize` and a header strip) and is bounded. */
|
|
45
|
+
export function mirrorBody(input: { task: number; reviewer: string; verdict: MirrorVerdict; note?: string }): string {
|
|
46
|
+
const head = mirrorHeadline(input.verdict, input.task, input.reviewer);
|
|
47
|
+
const note = (input.note ?? "").replace(/\r/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
|
|
48
|
+
if (!note) return head;
|
|
49
|
+
// A line of the note that starts like a verdict headline would read as a second mirror on the PR: keep it, indented, so it cannot.
|
|
50
|
+
const safe = sanitize(note).split("\n").map(line => line.startsWith(MARK) ? ` ${line}` : line).join("\n");
|
|
51
|
+
return `${head}\n\n${safe.length > MIRROR_NOTE_MAX ? `${safe.slice(0, MIRROR_NOTE_MAX)}\n[note cut]` : safe}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The only write this module makes. A closed shape: the endpoint is this PR's reviews, the event is COMMENT, and the commit is the verdict's head. */
|
|
55
|
+
export function mirrorArgv(repo: string, pr: number, head: string, body: string): string[] {
|
|
56
|
+
if (!REPO.test(repo) || repo.startsWith("-") || !Number.isSafeInteger(pr) || pr <= 0 || !SHA.test(head)) throw new Error("mirror argv needs owner/repository, a PR number and a full head");
|
|
57
|
+
return ["api", `repos/${repo}/pulls/${pr}/reviews`, "-f", "event=COMMENT", "-f", `commit_id=${head}`, "-f", `body=${body}`];
|
|
58
|
+
}
|
|
59
|
+
export function assertMirrorArgv(argv: readonly string[], head: string): void {
|
|
60
|
+
const [verb, endpoint, f1, event, f2, commit, f3, body, ...rest] = argv;
|
|
61
|
+
const shape = verb === "api" && /^repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pulls\/[1-9][0-9]*\/reviews$/.test(endpoint ?? "") && !endpoint!.startsWith("repos/-")
|
|
62
|
+
&& f1 === "-f" && event === "event=COMMENT" && f2 === "-f" && commit === `commit_id=${head}` && SHA.test(head) && f3 === "-f" && typeof body === "string" && body.startsWith(`body=${MARK}`) && rest.length === 0;
|
|
63
|
+
if (!shape) throw new Error("refusing a mirror argv outside the COMMENT review shape");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One mirror at a time per (task, head, verdict) in this process; the durable check below covers a restart and another process. */
|
|
67
|
+
const inFlight = new Set<string>();
|
|
68
|
+
|
|
69
|
+
/** The PR's current head and state in one bounded read; undefined when GitHub does not answer cleanly (never a pass). */
|
|
70
|
+
async function readHead(repo: string, pr: number, run: SdlcGh): Promise<{ head: string; open: boolean } | undefined> {
|
|
71
|
+
try {
|
|
72
|
+
const reply = await run(["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,state"]);
|
|
73
|
+
if (reply.code !== 0) return undefined;
|
|
74
|
+
const data = JSON.parse(reply.stdout);
|
|
75
|
+
return typeof data?.headRefOid === "string" && SHA.test(data.headRefOid) && typeof data.state === "string" ? { head: data.headRefOid, open: data.state === "OPEN" } : undefined;
|
|
76
|
+
} catch { return undefined; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Did this verdict already get a mirror at this head? Reads the PR's first 100 reviews, reduced by `--jq` to commit and first line only (no body text comes back). A full
|
|
81
|
+
* page, or any read that does not parse, is unknown: unknown posts nothing, because a duplicate on a person's PR is worse than a missing comment.
|
|
82
|
+
*/
|
|
83
|
+
async function alreadyMirrored(repo: string, pr: number, head: string, verdict: MirrorVerdict, task: number, run: SdlcGh): Promise<boolean | undefined> {
|
|
84
|
+
try {
|
|
85
|
+
const reply = await run(["api", `repos/${repo}/pulls/${pr}/reviews?per_page=100`, "--jq", "[.[] | {c: .commit_id, l: ((.body // \"\") | split(\"\\n\")[0])}]"]);
|
|
86
|
+
if (reply.code !== 0) return undefined;
|
|
87
|
+
const rows = JSON.parse(reply.stdout);
|
|
88
|
+
if (!Array.isArray(rows) || rows.length >= 100) return undefined;
|
|
89
|
+
const stable = stableHeadline(verdict, task);
|
|
90
|
+
return rows.some((row: { c?: unknown; l?: unknown }) => row?.c === head && typeof row.l === "string" && row.l.startsWith(stable));
|
|
91
|
+
} catch { return undefined; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The mirror. `off` does nothing and reads nothing. Otherwise: the PR must still be open at the verdict's head (a stale head posts nothing), a mirror of this verdict at this
|
|
96
|
+
* head must not exist yet (read from GitHub, so a restart or a retried verdict does not post twice), the head and state are read once more after that listing, and then
|
|
97
|
+
* `dry-run` reports the argv it would run while `on` posts it. Ceiling: GitHub offers no compare-and-set on a review, so a push that lands after the last read and before the
|
|
98
|
+
* post is not seen; the comment still names H in `commit_id` and in its headline, so it says truthfully which commit the verdict was about.
|
|
99
|
+
*/
|
|
100
|
+
export async function mirrorVerdict(input: MirrorInput): Promise<MirrorOutcome> {
|
|
101
|
+
if (input.mode === "off") return { mode: "off", posted: false, reason: "off" };
|
|
102
|
+
const { repo, pr, head, verdict, task, reviewer, run } = input;
|
|
103
|
+
if (!REPO.test(repo) || repo.startsWith("-") || !Number.isSafeInteger(pr) || pr <= 0 || !SHA.test(head) || !Number.isSafeInteger(task) || task <= 0 || !PEER.test(reviewer) && reviewer !== "user"
|
|
104
|
+
|| verdict !== "approved" && verdict !== "changes_requested") return { mode: input.mode, posted: false, reason: "invalid" };
|
|
105
|
+
const key = JSON.stringify([repo.toLowerCase(), pr, head, verdict, task]);
|
|
106
|
+
if (inFlight.has(key)) return { mode: input.mode, posted: false, reason: "in flight" };
|
|
107
|
+
inFlight.add(key);
|
|
108
|
+
try {
|
|
109
|
+
const current = await readHead(repo, pr, run);
|
|
110
|
+
if (!current) return { mode: input.mode, posted: false, reason: "unreadable" };
|
|
111
|
+
if (current.head !== head) return { mode: input.mode, posted: false, reason: "stale head" };
|
|
112
|
+
if (!current.open) return { mode: input.mode, posted: false, reason: "pr not open" };
|
|
113
|
+
const seen = await alreadyMirrored(repo, pr, head, verdict, task, run);
|
|
114
|
+
if (seen === undefined) return { mode: input.mode, posted: false, reason: "unreadable" };
|
|
115
|
+
if (seen) return { mode: input.mode, posted: false, reason: "already mirrored" };
|
|
116
|
+
// The review list was an await: a push or a close during it makes the first read stale. Read the head and state once more, LAST, and judge on that read; nothing is awaited
|
|
117
|
+
// between it and the post below. `commit_id` pins the comment to H but does not make H still current, so this read is what keeps "a stale head posts nothing" true.
|
|
118
|
+
const last = await readHead(repo, pr, run);
|
|
119
|
+
if (!last) return { mode: input.mode, posted: false, reason: "unreadable" };
|
|
120
|
+
if (last.head !== head) return { mode: input.mode, posted: false, reason: "stale head" };
|
|
121
|
+
if (!last.open) return { mode: input.mode, posted: false, reason: "pr not open" };
|
|
122
|
+
const argv = mirrorArgv(repo, pr, head, mirrorBody({ task, reviewer, verdict, ...(input.note !== undefined ? { note: input.note } : {}) }));
|
|
123
|
+
if (input.mode === "dry-run") return { mode: "dry-run", posted: false, reason: "dry-run", argv };
|
|
124
|
+
assertMirrorArgv(argv, head);
|
|
125
|
+
let exit = 1;
|
|
126
|
+
try { exit = (await run(argv, 10_000)).code; } catch { exit = 1; }
|
|
127
|
+
return { mode: "on", posted: exit === 0, reason: exit === 0 ? "posted" : "post failed", argv };
|
|
128
|
+
} finally { inFlight.delete(key); }
|
|
129
|
+
}
|