@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/README.md +314 -0
- package/bin/supervisor.mjs +614 -0
- package/package.json +19 -0
- package/src/labels.mjs +68 -0
- package/src/managedContainer.mjs +120 -0
- package/src/reviewGate.mjs +308 -0
- package/src/reviewMarker.mjs +80 -0
- package/src/workerLogs.mjs +175 -0
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
|
+
}
|