@nickysagan/issue-orchestrator 0.1.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/src/labels.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // Label vocabulary for the managed workflow. `agent-running` is the durable
2
+ // ownership marker; exactly one phase label is active alongside it.
3
+ export const READY = "agent-ready";
4
+ export const RUNNING = "agent-running";
5
+ export const REVIEW = "agent-review";
6
+ export const BLOCKED = "agent-blocked";
7
+ export const MERGE_REVIEW = "user-merge-review";
8
+
9
+ // Declaration order is also precedence order for `phaseOf`: a repository that
10
+ // somehow carries two phase labels resolves to the earliest one, so the
11
+ // supervisor keeps reconciling rather than oscillating.
12
+ export const PHASE_LABELS = [REVIEW, BLOCKED, MERGE_REVIEW];
13
+ export const MANAGED_LABELS = [READY, RUNNING, ...PHASE_LABELS];
14
+
15
+ export const REQUIRED_LABELS = [
16
+ { name: READY, color: "0e8a16", description: "Ready for issue-orchestrator claim" },
17
+ { name: RUNNING, color: "1d76db", description: "Managed by issue-orchestrator" },
18
+ { name: REVIEW, color: "5319e7", description: "Waiting for or undergoing automated review" },
19
+ { name: BLOCKED, color: "d73a4a", description: "Automated workflow blocked; findings posted" },
20
+ { name: MERGE_REVIEW, color: "fbca04", description: "Automated review passed; waiting for owner review and merge" },
21
+ ];
22
+
23
+ export function phaseOf(labelNames) {
24
+ const present = new Set((labelNames || []).map((name) => String(name)));
25
+ return PHASE_LABELS.find((label) => present.has(label)) || null;
26
+ }
27
+
28
+ function alreadyExists(message) {
29
+ return /already exists/i.test(message);
30
+ }
31
+
32
+ // Idempotent bootstrap: list once, create only what is missing, never modify
33
+ // an existing label. A permission/authentication failure rejects, which is
34
+ // fatal at startup; whatever was created before the failure is kept, so the
35
+ // next startup retries only the remainder.
36
+ export async function ensureLabels({ listLabels, createLabel, log = () => {} }) {
37
+ let existingLabels;
38
+ try {
39
+ existingLabels = await listLabels();
40
+ } catch (err) {
41
+ throw new Error(`label bootstrap failed: ${err.message}`);
42
+ }
43
+ // GitHub label names are case-insensitive for uniqueness, so a repository
44
+ // that already has `Agent-Ready` must not get a second `agent-ready`.
45
+ const byName = new Map(
46
+ (existingLabels || []).map((label) => [String(label.name).toLowerCase(), label.name]),
47
+ );
48
+
49
+ const created = [];
50
+ const existing = [];
51
+ for (const label of REQUIRED_LABELS) {
52
+ const match = byName.get(label.name.toLowerCase());
53
+ if (match !== undefined) {
54
+ existing.push(match);
55
+ continue;
56
+ }
57
+ try {
58
+ await createLabel(label);
59
+ } catch (err) {
60
+ if (!alreadyExists(err.message)) {
61
+ throw new Error(`label bootstrap failed: ${err.message}`);
62
+ }
63
+ }
64
+ created.push(label.name);
65
+ }
66
+ if (created.length > 0) log(`Created missing labels: ${created.join(", ")}`);
67
+ return { created, existing };
68
+ }
@@ -0,0 +1,120 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ const DEFAULT_URL = "http://usage-sentinel:4317";
4
+ const DEFAULT_TIMEOUT_MS = 5000;
5
+ const CONTAINER_ID = /^[0-9a-f]{64}$/;
6
+ // Docker bind-mounts /etc/{resolv.conf,hostname,hosts} out of the container's
7
+ // own state directory, so mountinfo carries the full 64-hex ID that the lease
8
+ // API requires. `hostname` is only the 12-character short form, which Sentinel
9
+ // rejects, and cgroup v2 reports `0::/` with no ID at all.
10
+ const MOUNT_CONTAINER_ID = /\/containers\/([0-9a-f]{64})\//g;
11
+
12
+ export async function resolveContainerId({ readFileImpl = readFile } = {}) {
13
+ const raw = await readFileImpl("/proc/self/mountinfo", "utf8");
14
+ const ids = new Set(Array.from(String(raw).matchAll(MOUNT_CONTAINER_ID), (match) => match[1]));
15
+ if (ids.size === 1) return [...ids][0];
16
+ if (ids.size === 0) {
17
+ throw new Error("cannot resolve the current Docker container ID from /proc/self/mountinfo");
18
+ }
19
+ throw new Error(`ambiguous Docker container IDs in /proc/self/mountinfo: ${[...ids].join(", ")}`);
20
+ }
21
+
22
+ // Sentinel's `/managed-containers` API is a lease, not admission control: it
23
+ // never allows or denies a start, so neither operation here returns anything a
24
+ // caller could read as a decision. Any non-lease status is a local or transport
25
+ // fault to report, never a refusal to run.
26
+ export function createManagedContainerClient({
27
+ url = DEFAULT_URL,
28
+ fetchImpl = fetch,
29
+ timeoutMs = DEFAULT_TIMEOUT_MS,
30
+ } = {}) {
31
+ const baseUrl = url.replace(/\/$/, "");
32
+
33
+ // One deadline for the whole exchange — headers *and* body. Sentinel can send
34
+ // headers and then stall mid-body; awaiting that without a deadline would hang
35
+ // the supervisor's poll loop. The timer is deliberately ref'd (unlike
36
+ // `AbortSignal.timeout`, whose unref'd timer never fires when nothing else
37
+ // keeps the loop alive) and is cleared only once the caller is done reading.
38
+ function startDeadline() {
39
+ const controller = new AbortController();
40
+ const timer = setTimeout(
41
+ () => controller.abort(new Error(`exceeded ${timeoutMs}ms`)),
42
+ timeoutMs,
43
+ );
44
+ return { signal: controller.signal, done: () => clearTimeout(timer) };
45
+ }
46
+
47
+ // The race does not trust the awaited promise to honour the signal itself.
48
+ function withDeadline(promise, signal) {
49
+ if (signal.aborted) return Promise.reject(signal.reason);
50
+ return Promise.race([
51
+ promise,
52
+ new Promise((_resolve, reject) => {
53
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
54
+ }),
55
+ ]);
56
+ }
57
+
58
+ function transportError(error, signal) {
59
+ const detail = error instanceof Error ? error.message : String(error);
60
+ const prefix = signal.aborted ? "sentinel request timed out" : "sentinel unreachable";
61
+ return new Error(`${prefix}: ${detail}`);
62
+ }
63
+
64
+ async function request(containerId, method) {
65
+ if (typeof containerId !== "string" || !CONTAINER_ID.test(containerId)) {
66
+ throw new Error(`invalid container ID: ${containerId}`);
67
+ }
68
+ const { signal, done } = startDeadline();
69
+ const path = `/managed-containers/${encodeURIComponent(containerId)}`;
70
+ let response;
71
+ try {
72
+ response = await withDeadline(fetchImpl(`${baseUrl}${path}`, { method, signal }), signal);
73
+ if (!response || !Number.isInteger(response.status)) {
74
+ throw new Error("sentinel returned no response");
75
+ }
76
+ } catch (error) {
77
+ done();
78
+ if (error.message === "sentinel returned no response") throw error;
79
+ throw transportError(error, signal);
80
+ }
81
+ // The deadline stays armed for whatever the caller still reads off this
82
+ // response; `done` disarms it.
83
+ return { response, signal, done };
84
+ }
85
+
86
+ async function register(containerId) {
87
+ const { response, signal, done } = await request(containerId, "PUT");
88
+ try {
89
+ // 201 creates the lease; a repeat PUT is its heartbeat and returns 200.
90
+ const expected = { 201: "registered", 200: "refreshed" }[response.status];
91
+ if (!expected) {
92
+ throw new Error(`unexpected managed-container registration status ${response.status}`);
93
+ }
94
+ let body;
95
+ try {
96
+ body = await withDeadline(response.json(), signal);
97
+ } catch (error) {
98
+ if (signal.aborted) throw transportError(error, signal);
99
+ const detail = error instanceof Error ? error.message : String(error);
100
+ throw new Error(`invalid managed-container registration response: ${detail}`);
101
+ }
102
+ if (body?.status !== expected) {
103
+ throw new Error(`invalid managed-container registration response: status ${body?.status}`);
104
+ }
105
+ return { status: expected };
106
+ } finally {
107
+ done();
108
+ }
109
+ }
110
+
111
+ async function unregister(containerId) {
112
+ const { response, done } = await request(containerId, "DELETE");
113
+ done();
114
+ if (response.status !== 204) {
115
+ throw new Error(`unexpected managed-container deletion status ${response.status}`);
116
+ }
117
+ }
118
+
119
+ return { register, unregister };
120
+ }
@@ -0,0 +1,308 @@
1
+ import { BLOCKED, MANAGED_LABELS, MERGE_REVIEW, REVIEW, phaseOf } from "./labels.mjs";
2
+
3
+ const BRANCH_PREFIX = /^agent\/(\d+)-/;
4
+
5
+ function branchIssue(pr) {
6
+ const match = BRANCH_PREFIX.exec(String(pr.headRefName || ""));
7
+ return match ? Number(match[1]) : null;
8
+ }
9
+
10
+ function closingIssues(pr) {
11
+ const refs = Array.isArray(pr.closingIssuesReferences) ? pr.closingIssuesReferences : [];
12
+ return refs.map((ref) => Number(ref?.number)).filter((n) => Number.isInteger(n));
13
+ }
14
+
15
+ // Pair each `agent-running` issue with the single open PR that closes it.
16
+ // Closing references are authoritative; the `agent/<n>-` branch prefix is only
17
+ // a fallback for a PR that declares no closing reference at all. A PR whose
18
+ // branch names a different issue than its closing reference is a conflict, not
19
+ // a match, and fails closed. PRs that match no managed issue — manual
20
+ // `/github-issue` PRs, unrelated `agent/*` branches — are ignored entirely.
21
+ export function resolveManagedPrs(issues, prs) {
22
+ const managed = [];
23
+ const problems = [];
24
+ const ordered = [...(issues || [])].sort((a, b) => a.number - b.number);
25
+
26
+ for (const issue of ordered) {
27
+ const phase = phaseOf(issue.labels);
28
+ const candidates = [];
29
+ for (const pr of prs || []) {
30
+ const closes = closingIssues(pr);
31
+ const branch = branchIssue(pr);
32
+ if (closes.includes(issue.number)) {
33
+ if (branch !== null && branch !== issue.number) {
34
+ problems.push({
35
+ issue,
36
+ pr,
37
+ reason: "conflicting-linkage",
38
+ message: `PR #${pr.number} closes #${issue.number} but its branch \`${pr.headRefName}\` names #${branch}.`,
39
+ });
40
+ continue;
41
+ }
42
+ candidates.push(pr);
43
+ } else if (closes.length === 0 && branch === issue.number) {
44
+ candidates.push(pr);
45
+ }
46
+ }
47
+
48
+ if (candidates.length === 1) {
49
+ managed.push({ issue, pr: candidates[0] });
50
+ } else if (candidates.length > 1) {
51
+ problems.push({
52
+ issue,
53
+ pr: null,
54
+ reason: "duplicate-linkage",
55
+ message: `Issue #${issue.number} is closed by more than one open PR: ${candidates.map((p) => `#${p.number}`).join(", ")}.`,
56
+ });
57
+ } else if (phase === REVIEW) {
58
+ // Only a review-phase issue *must* have a PR. An issue still being
59
+ // implemented legitimately has none yet, and a terminal issue's PR may
60
+ // already be closed.
61
+ problems.push({
62
+ issue,
63
+ pr: null,
64
+ reason: "missing-linkage",
65
+ message: `Issue #${issue.number} is labelled \`${REVIEW}\` but no open PR closes it.`,
66
+ });
67
+ }
68
+ }
69
+ return { managed, problems };
70
+ }
71
+
72
+ const GREEN_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
73
+ const FAILING_CONCLUSIONS = new Set([
74
+ "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE",
75
+ ]);
76
+ const PENDING_STATUSES = new Set(["QUEUED", "IN_PROGRESS", "WAITING", "PENDING", "REQUESTED"]);
77
+ const STUCK_CHECK_MS = 6 * 60 * 60 * 1000;
78
+
79
+ function checkName(entry) {
80
+ return entry.name ?? entry.context ?? "(unnamed check)";
81
+ }
82
+ function checkUrl(entry) {
83
+ return entry.detailsUrl ?? entry.targetUrl ?? null;
84
+ }
85
+
86
+ // Normalise one rollup entry. GitHub reports modern CheckRuns (status +
87
+ // conclusion) and legacy StatusContexts (state) in the same array.
88
+ function checkState(entry) {
89
+ if (!entry || typeof entry !== "object") return "unknown";
90
+ if (typeof entry.state === "string") {
91
+ if (entry.state === "SUCCESS") return "green";
92
+ if (entry.state === "PENDING" || entry.state === "EXPECTED") return "pending";
93
+ if (entry.state === "FAILURE" || entry.state === "ERROR") return "failing";
94
+ return "unknown";
95
+ }
96
+ if (typeof entry.status !== "string") return "unknown";
97
+ if (PENDING_STATUSES.has(entry.status)) return "pending";
98
+ if (entry.status !== "COMPLETED") return "unknown";
99
+ if (GREEN_CONCLUSIONS.has(entry.conclusion)) return "green";
100
+ if (FAILING_CONCLUSIONS.has(entry.conclusion)) return "failing";
101
+ return "unknown";
102
+ }
103
+
104
+ function pendingElapsedMs(entry, now) {
105
+ const timestamp = typeof entry.state === "string" ? entry.createdAt : entry.startedAt;
106
+ if (typeof timestamp !== "string") return null;
107
+ const started = Date.parse(timestamp);
108
+ const elapsed = now - started;
109
+ return Number.isFinite(started) && Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : null;
110
+ }
111
+
112
+ function timedCheckState(entry, now) {
113
+ const state = checkState(entry);
114
+ if (state !== "pending") return state;
115
+ const elapsed = pendingElapsedMs(entry, now);
116
+ if (elapsed === null) return "unknown";
117
+ return elapsed > STUCK_CHECK_MS ? "stuck" : "pending";
118
+ }
119
+
120
+ // Every check GitHub reports is treated as required: reading branch-protection
121
+ // required-check names needs an extra admin-scoped call and could only ever
122
+ // make this gate more permissive.
123
+ //
124
+ // An empty array means "no checks configured" and counts as green. Absent or
125
+ // unreadable data is `unknown`, which transitions nothing — the two must never
126
+ // be conflated, or a failed fetch would silently promote an unverified PR.
127
+ export function classifyChecks(rollup, now = Date.now()) {
128
+ if (!Array.isArray(rollup)) return "unknown";
129
+ const states = rollup.map((entry) => timedCheckState(entry, now));
130
+ if (states.includes("failing")) return "failing";
131
+ if (states.includes("unknown")) return "unknown";
132
+ if (states.includes("stuck")) return "stuck";
133
+ if (states.includes("pending")) return "pending";
134
+ return "green";
135
+ }
136
+
137
+ export function failingChecks(rollup) {
138
+ if (!Array.isArray(rollup)) return [];
139
+ return rollup
140
+ .filter((entry) => checkState(entry) === "failing")
141
+ .map((entry) => ({ name: checkName(entry), url: checkUrl(entry) }));
142
+ }
143
+
144
+ export function stuckChecks(rollup, now = Date.now()) {
145
+ if (!Array.isArray(rollup)) return [];
146
+ return rollup
147
+ .filter((entry) => timedCheckState(entry, now) === "stuck")
148
+ .map((entry) => ({
149
+ name: checkName(entry),
150
+ url: checkUrl(entry),
151
+ elapsedMs: pendingElapsedMs(entry, now),
152
+ }));
153
+ }
154
+
155
+ // One action per PR per poll.
156
+ //
157
+ // A clean verdict marks a draft PR ready and stops there ("open"): checks were
158
+ // read before the PR opened, so a repository whose CI triggers on
159
+ // `ready_for_review` would still report an empty rollup and be promoted
160
+ // without ever running. The next poll evaluates the checks for real.
161
+ // Verdicts are the producer's exact literals (review-pr:v1). Anything else —
162
+ // including the old lowercase spellings — is a contract violation and fails
163
+ // closed rather than being re-reviewed forever.
164
+ export function planVerdict({ marker, checks, isDraft }) {
165
+ if (!marker) return { action: "review" };
166
+ if (marker.verdict === "BLOCKING") return { action: "block", reason: "blocking-verdict" };
167
+ if (marker.verdict !== "PASS") return { action: "block", reason: "unrecognised-verdict" };
168
+ if (isDraft) return { action: "open" };
169
+ if (checks === "green") return { action: "pass" };
170
+ if (checks === "pending") return { action: "wait" };
171
+ if (checks === "failing") return { action: "fail-checks" };
172
+ if (checks === "stuck") return { action: "stuck-checks" };
173
+ return { action: "retry" };
174
+ }
175
+
176
+ // PR labels mirror the canonical issue labels, restricted to the managed
177
+ // vocabulary so a reviewer's own labels are never disturbed. Returning empty
178
+ // lists lets the caller skip the API call entirely when nothing differs.
179
+ export function planLabelMirror(issueLabels, prLabels) {
180
+ const managed = new Set(MANAGED_LABELS);
181
+ const desired = (issueLabels || []).filter((name) => managed.has(name));
182
+ const current = new Set((prLabels || []).filter((name) => managed.has(name)));
183
+ const wanted = new Set(desired);
184
+ return {
185
+ add: desired.filter((name) => !current.has(name)),
186
+ remove: [...current].filter((name) => !wanted.has(name)),
187
+ };
188
+ }
189
+
190
+ // Swap the issue's phase label and mirror it onto the PR. `agent-running` is
191
+ // never removed here — it is durable until Phase 7.
192
+ async function setPhase({ gh, issue, pr, phase }) {
193
+ const current = phaseOf(issue.labels);
194
+ if (current === phase) return;
195
+ const delta = { add: [phase], remove: current ? [current] : [] };
196
+ await gh.setIssueLabels(issue.number, delta);
197
+ if (pr) await gh.setPrLabels(pr.number, delta);
198
+ }
199
+
200
+ export async function mirrorLabels({ gh, issue, pr }) {
201
+ if (!pr) return;
202
+ const delta = planLabelMirror(issue.labels, pr.labels);
203
+ if (delta.add.length === 0 && delta.remove.length === 0) return;
204
+ await gh.setPrLabels(pr.number, delta);
205
+ }
206
+
207
+ function failingChecksComment(rollup) {
208
+ const lines = failingChecks(rollup).map(
209
+ (check) => `- \`${check.name}\`${check.url ? ` — ${check.url}` : ""}`,
210
+ );
211
+ return [
212
+ "Automated review passed, but required checks failed.",
213
+ "",
214
+ ...lines,
215
+ "",
216
+ `Returned to draft and labelled \`${BLOCKED}\`. This workflow does not repair failures.`,
217
+ ].join("\n");
218
+ }
219
+
220
+ function elapsedDuration(elapsedMs) {
221
+ const hours = Math.floor(elapsedMs / (60 * 60 * 1000));
222
+ if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"}`;
223
+ const minutes = Math.max(1, Math.floor(elapsedMs / (60 * 1000)));
224
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
225
+ }
226
+
227
+ function stuckChecksComment(rollup, now) {
228
+ const lines = stuckChecks(rollup, now).map(
229
+ (check) => `- \`${check.name}\` — stuck for ${elapsedDuration(check.elapsedMs)}${check.url ? ` — ${check.url}` : ""}`,
230
+ );
231
+ return [
232
+ "Automated review passed, but required checks appear stuck.",
233
+ "",
234
+ ...lines,
235
+ "",
236
+ `Returned to draft and labelled \`${BLOCKED}\` for owner attention.`,
237
+ ].join("\n");
238
+ }
239
+
240
+ // Execute exactly one planned action. Returns the phase the issue now carries
241
+ // so the caller can decide whether the supervisor may exit.
242
+ export async function applyReviewPlan({ gh, issue, pr, plan, rollup, now = Date.now(), log = () => {} }) {
243
+ switch (plan.action) {
244
+ case "open":
245
+ await gh.markPrReady(pr.number);
246
+ log(`#${issue.number}: review clean — PR #${pr.number} marked ready; checks evaluated next poll`);
247
+ return { phase: REVIEW };
248
+
249
+ case "pass":
250
+ await setPhase({ gh, issue, pr, phase: MERGE_REVIEW });
251
+ log(`#${issue.number}: checks green — PR #${pr.number} awaiting owner review`);
252
+ return { phase: MERGE_REVIEW };
253
+
254
+ case "wait":
255
+ log(`#${issue.number}: PR #${pr.number} checks pending — reviewer slot free`);
256
+ return { phase: REVIEW };
257
+
258
+ case "retry":
259
+ log(`#${issue.number}: PR #${pr.number} check data unreadable — retrying next poll`);
260
+ return { phase: REVIEW };
261
+
262
+ case "block": {
263
+ if (plan.reason === "unrecognised-verdict") {
264
+ await gh.commentPr(
265
+ pr.number,
266
+ "The review marker for this PR's current content carries an unrecognised verdict, so this PR cannot be gated automatically. Blocking for owner attention.",
267
+ );
268
+ }
269
+ if (pr.isDraft === false) await gh.markPrDraft(pr.number);
270
+ await setPhase({ gh, issue, pr, phase: BLOCKED });
271
+ log(`#${issue.number}: PR #${pr.number} blocked (${plan.reason})`);
272
+ return { phase: BLOCKED };
273
+ }
274
+
275
+ case "fail-checks": {
276
+ await gh.commentPr(pr.number, failingChecksComment(rollup));
277
+ if (pr.isDraft === false) await gh.markPrDraft(pr.number);
278
+ await setPhase({ gh, issue, pr, phase: BLOCKED });
279
+ log(`#${issue.number}: PR #${pr.number} blocked on failing checks`);
280
+ return { phase: BLOCKED };
281
+ }
282
+
283
+ case "stuck-checks": {
284
+ await gh.commentPr(pr.number, stuckChecksComment(rollup, now));
285
+ if (pr.isDraft === false) await gh.markPrDraft(pr.number);
286
+ await setPhase({ gh, issue, pr, phase: BLOCKED });
287
+ log(`#${issue.number}: PR #${pr.number} blocked on stuck checks`);
288
+ return { phase: BLOCKED };
289
+ }
290
+
291
+ default:
292
+ return { phase: phaseOf(issue.labels) };
293
+ }
294
+ }
295
+
296
+ // Every linkage or lifecycle ambiguity ends here: an actionable comment plus
297
+ // `agent-blocked`. Already-blocked issues short-circuit so a persistent
298
+ // problem is reported once, not every poll.
299
+ export async function failClosed({ gh, issue, pr, message, log = () => {} }) {
300
+ if (phaseOf(issue.labels) === BLOCKED) return;
301
+ await gh.commentIssue(
302
+ issue.number,
303
+ `${message}\n\nissue-orchestrator cannot proceed automatically and has labelled this \`${BLOCKED}\`.`,
304
+ );
305
+ if (pr && pr.isDraft === false) await gh.markPrDraft(pr.number);
306
+ await setPhase({ gh, issue, pr, phase: BLOCKED });
307
+ log(`#${issue.number}: blocked — ${message}`);
308
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ // The contract between the reviewer skill (Sadotu/agent-skills#22) and this
4
+ // supervisor. This repo is purely a consumer: the shipped
5
+ // `review-pr/scripts/publish-review.sh` is canonical and nothing here ever
6
+ // produces a marker.
7
+ //
8
+ // The producer emits exactly one line per pass:
9
+ // <!-- review-pr:v1 {"fingerprint":…,"head":…,…,"verdict":"PASS"} -->
10
+ export const MARKER_TAG = "review-pr:v1";
11
+
12
+ const MARKER = /^<!-- review-pr:v1 (\{.*\}) -->$/gm;
13
+ const TEXT_FIELDS = ["fingerprint", "head", "base", "issueUpdatedAt", "prUpdatedAt"];
14
+ const NUMBER_FIELDS = ["issue", "pr", "pass"];
15
+ const SEPARATOR = "\x1e";
16
+
17
+ // The producer captures bodies as `body="$(… | jq -r .body)"`; command
18
+ // substitution strips trailing newlines before they reach sha256. Replicate
19
+ // that and nothing else — any further normalisation (trimming, CRLF folding)
20
+ // yields a different digest and would reject every real marker.
21
+ function hashedBody(text) {
22
+ return String(text ?? "").replace(/\n+$/, "");
23
+ }
24
+
25
+ // sha256 of head, base, issue body and PR body joined by the ASCII record
26
+ // separator, byte-identical to the producer's `compute_fingerprint`. Bodies —
27
+ // not updatedAt — decide freshness, so a label edit or a new comment cannot
28
+ // stale a review, while a real content change always does.
29
+ export function computeFingerprint({ head, base, issueBody, prBody }) {
30
+ const parts = [String(head ?? ""), String(base ?? ""), hashedBody(issueBody), hashedBody(prBody)];
31
+ return createHash("sha256").update(parts.join(SEPARATOR), "utf8").digest("hex");
32
+ }
33
+
34
+ // Fail closed: an unsupported tag, malformed JSON, or a missing/ill-typed
35
+ // field yields no marker at all, so it can never become an applicable review.
36
+ // An unrecognised *verdict* is deliberately kept — the caller blocks on it
37
+ // rather than relaunching a reviewer forever on input that will never parse.
38
+ export function parseReviewMarkers(body) {
39
+ const markers = [];
40
+ // A web-UI edit can rewrite the comment with CRLF endings; strip CR first so
41
+ // the line-anchored match still sees the producer's exact line.
42
+ const text = String(body || "").replaceAll("\r", "");
43
+ for (const [, payload] of text.matchAll(MARKER)) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(payload);
47
+ } catch {
48
+ continue;
49
+ }
50
+ if (!parsed || typeof parsed !== "object") continue;
51
+ if (!TEXT_FIELDS.every((f) => typeof parsed[f] === "string" && parsed[f].length > 0)) continue;
52
+ if (!NUMBER_FIELDS.every((f) => Number.isInteger(parsed[f]))) continue;
53
+ if (typeof parsed.verdict !== "string") continue;
54
+ markers.push({
55
+ ...Object.fromEntries(TEXT_FIELDS.map((f) => [f, parsed[f]])),
56
+ ...Object.fromEntries(NUMBER_FIELDS.map((f) => [f, parsed[f]])),
57
+ verdict: parsed.verdict,
58
+ });
59
+ }
60
+ return markers;
61
+ }
62
+
63
+ // A marker counts only if the reviewing App identity authored it
64
+ // (`viewerDidAuthor`), it names this exact issue/PR pair, and the state it
65
+ // says it reviewed still equals live state. Every fingerprint input is public,
66
+ // so without the author check any commenter could forge a PASS. Comparing
67
+ // against live state needs no persistence, so a marker survives a supervisor
68
+ // restart or a crash between the reviewer's comment and the label transition.
69
+ export function selectApplicableMarker(comments, live) {
70
+ const want = computeFingerprint(live);
71
+ let found = null;
72
+ for (const comment of comments || []) {
73
+ if (comment?.viewerDidAuthor !== true) continue;
74
+ for (const marker of parseReviewMarkers(comment.body)) {
75
+ if (marker.issue !== live.issue || marker.pr !== live.pr) continue;
76
+ if (marker.fingerprint === want) found = marker;
77
+ }
78
+ }
79
+ return found;
80
+ }