@nanobpm/nano-workforce 0.206.4 → 0.208.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 +12 -0
- package/SPEC.md +18 -0
- package/app/deliveryGraphReadModel.test.ts +4 -2
- package/app/deliveryGraphRun.ts +12 -0
- package/app/github.ts +29 -0
- package/app/incidentStatus.test.ts +175 -0
- package/app/mcpExclusions.test.ts +4 -3
- package/app/mcpToolSurface.ts +11 -2
- package/app/mergeLoopBehaviour.test.ts +136 -1
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +266 -0
- package/app/service.ts +311 -96
- package/build-info.json +4 -4
- package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
- package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
- package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
- package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
- package/docs/mcp-runbook.md +2 -2
- package/nano.app.json +8 -0
- package/openapi.yaml +238 -0
- package/operations/listIncidents.test.ts +70 -0
- package/operations/listIncidents.ts +78 -0
- package/operations/retryIncident.test.ts +280 -0
- package/operations/retryIncident.ts +201 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +16 -3
- package/pages/home.page.json +19 -0
- package/pages/overview.page.json +9 -0
- package/resources/processes/merge-loop.bpmn +401 -213
- package/workers/merge/worker.test.ts +30 -9
- package/workers/merge/worker.ts +23 -12
- package/workers/reconverge/worker.test.ts +110 -0
- package/workers/reconverge/worker.ts +81 -0
- package/workers/request-review/worker.test.ts +54 -0
- package/workers/request-review/worker.ts +68 -0
|
@@ -329,14 +329,13 @@ test("pr.merge #923: refuses to land when NO Copilot review exists (the #1443 fa
|
|
|
329
329
|
app,
|
|
330
330
|
)) as Record<string, unknown>;
|
|
331
331
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
assertEquals(
|
|
335
|
-
assertEquals((out.question as string).includes("no head-current Copilot review"), true);
|
|
332
|
+
// Issue #939: an automatable remedy → `review-pending` (the model requests + waits), NEVER a
|
|
333
|
+
// human escalation payload on the first occurrence.
|
|
334
|
+
assertEquals(out, { mergeStatus: "review-pending" });
|
|
336
335
|
|
|
337
336
|
// The refusal is audited, and NO land was attempted (no merge PUT, no enqueue comment).
|
|
338
337
|
assertEquals(stores.merges.length, 1);
|
|
339
|
-
assertEquals(stores.merges[0].outcome, "
|
|
338
|
+
assertEquals(stores.merges[0].outcome, "review-pending");
|
|
340
339
|
assertEquals(stores.merges[0].method, "review-backstop");
|
|
341
340
|
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
342
341
|
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
@@ -356,7 +355,7 @@ test("pr.merge #923: refuses to land when the head MOVED past the latest review
|
|
|
356
355
|
app,
|
|
357
356
|
)) as Record<string, unknown>;
|
|
358
357
|
|
|
359
|
-
assertEquals(out
|
|
358
|
+
assertEquals(out, { mergeStatus: "review-pending" });
|
|
360
359
|
assertEquals(stores.merges[0].method, "review-backstop");
|
|
361
360
|
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
362
361
|
});
|
|
@@ -440,7 +439,7 @@ test("pr.merge #927: reproduces nano-bpm#1467 — review REQUESTED but not yet a
|
|
|
440
439
|
{ variables: { prKey: "acme/widgets#1467", repo: "acme/widgets", prNumber: 1467 } } as any,
|
|
441
440
|
app,
|
|
442
441
|
)) as Record<string, unknown>;
|
|
443
|
-
assertEquals(out.mergeStatus,
|
|
442
|
+
assertEquals(["blocked", "review-pending"].includes(String(out.mergeStatus)), true);
|
|
444
443
|
assertEquals(stores.merges[0].method, "review-backstop");
|
|
445
444
|
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
446
445
|
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
@@ -457,8 +456,9 @@ test("pr.merge #923: a head-current review whose summary reports OPEN findings b
|
|
|
457
456
|
readHeadSha: async () => "h1",
|
|
458
457
|
});
|
|
459
458
|
const out = (await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app)) as Record<string, unknown>;
|
|
460
|
-
|
|
461
|
-
assertEquals(
|
|
459
|
+
// Issue #939: findings on a head-current review re-enter convergence, never a human.
|
|
460
|
+
assertEquals(out, { mergeStatus: "review-findings" });
|
|
461
|
+
assertEquals(stores.merges[0].outcome, "review-findings");
|
|
462
462
|
assertEquals(stores.merges[0].method, "review-backstop");
|
|
463
463
|
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
464
464
|
});
|
|
@@ -538,3 +538,24 @@ for (const [label, finalRead] of [
|
|
|
538
538
|
);
|
|
539
539
|
});
|
|
540
540
|
}
|
|
541
|
+
|
|
542
|
+
// ── Defect-class guard (issue #939) ──────────────────────────────────────────
|
|
543
|
+
// No merge-gate refusal whose remedy is fully automatable (missing head-current review, a review
|
|
544
|
+
// made stale by a loop push, findings on a head-current review) may produce a human escalation
|
|
545
|
+
// payload (`status`/`question`) on its first occurrence.
|
|
546
|
+
for (const [label, review, head] of [
|
|
547
|
+
["no review ever submitted", { body: "", commitId: null }, "h10"],
|
|
548
|
+
["review stale after a loop push", { body: "lgtm", commitId: "old" }, "h10"],
|
|
549
|
+
["head-current review with findings", { body: "### 🟡 Changes recommended\n<summary>1 open issue</summary>", commitId: "h10" }, "h10"],
|
|
550
|
+
] as const) {
|
|
551
|
+
test(`pr.merge #939: an automatable refusal (${label}) never yields a human escalation payload`, async () => {
|
|
552
|
+
await withGithub(backstopRoutes, async () => {
|
|
553
|
+
const { app } = fakeApp();
|
|
554
|
+
const backstop = makeHandler({ readReview: async () => review, readHeadSha: async () => head });
|
|
555
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#40", repo: "acme/widgets", prNumber: 40 } } as any, app)) as Record<string, unknown>;
|
|
556
|
+
assertEquals(out.status, undefined);
|
|
557
|
+
assertEquals(out.question, undefined);
|
|
558
|
+
assertEquals(out.mergeStatus === "blocked", false);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
package/workers/merge/worker.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
|
42
42
|
type In = WorkerInputs["pr.merge"];
|
|
43
43
|
|
|
44
44
|
interface Out extends Record<string, unknown> {
|
|
45
|
-
mergeStatus: "merged" | "queued" | "blocked" | "retry" | "abandoned";
|
|
45
|
+
mergeStatus: "merged" | "queued" | "blocked" | "retry" | "abandoned" | "review-pending" | "review-findings";
|
|
46
46
|
status?: string;
|
|
47
47
|
question?: string;
|
|
48
48
|
}
|
|
@@ -142,11 +142,26 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
142
142
|
// FAIL CLOSED (issue #927): a gate whose job is "never land unreviewed" must not land on an
|
|
143
143
|
// UNVERIFIABLE read. nano-bpm#1467 merged 49 s before its first Copilot review because a `null`
|
|
144
144
|
// review/head read (or an empty head) skipped this check entirely. An unreadable review or head now
|
|
145
|
-
// blocks (escalating to a human, who replies to retry)
|
|
145
|
+
// blocks (escalating to a human, who replies to retry); a VERIFIED missing/stale review instead
|
|
146
|
+
// takes the automated request-and-wait path below (issue #939).
|
|
146
147
|
const headTrimmed = (backstopHead ?? "").trim();
|
|
147
148
|
const unverifiable = backstopReview === null || headTrimmed === "";
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
// AUTOMATABLE REMEDY (issue #939): a VERIFIED read showing no head-current review (the loop's own
|
|
150
|
+
// rebase/fix push moved the head, or no review exists yet) is not a human decision — the only
|
|
151
|
+
// remedy is "request a Copilot review and wait for it". Return `review-pending` so the model
|
|
152
|
+
// requests the review itself and waits on a bounded timer, escalating only when it never arrives.
|
|
153
|
+
if (!unverifiable && isReviewMissingOrStale(backstopReview.commitId, headTrimmed)) {
|
|
154
|
+
await app.data.table("merges", "id").insert({
|
|
155
|
+
pr_key: prKey,
|
|
156
|
+
outcome: "review-pending",
|
|
157
|
+
method: "review-backstop",
|
|
158
|
+
detail: BLOCK_NO_HEAD_CURRENT_REVIEW,
|
|
159
|
+
at: now,
|
|
160
|
+
});
|
|
161
|
+
return { mergeStatus: "review-pending" };
|
|
162
|
+
}
|
|
163
|
+
if (unverifiable) {
|
|
164
|
+
const detail = BLOCK_UNVERIFIABLE_REVIEW;
|
|
150
165
|
await app.data.table("merges", "id").insert({
|
|
151
166
|
pr_key: prKey,
|
|
152
167
|
outcome: "blocked",
|
|
@@ -174,18 +189,14 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
174
189
|
const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
|
|
175
190
|
await app.data.table("merges", "id").insert({
|
|
176
191
|
pr_key: prKey,
|
|
177
|
-
outcome: "
|
|
192
|
+
outcome: "review-findings",
|
|
178
193
|
method: "review-backstop",
|
|
179
194
|
detail,
|
|
180
195
|
at: now,
|
|
181
196
|
});
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
question:
|
|
186
|
-
`Merge refused: ${detail}. Address the review's findings on the branch (or reply with ` +
|
|
187
|
-
`guidance on which to push back on), let the convergence loop gate on a clean review, then reply to retry.`,
|
|
188
|
-
};
|
|
197
|
+
// Findings on a head-current review belong to the convergence path (fix → push → review), not
|
|
198
|
+
// to a human (issue #939): the model hands the PR back to the convergence loop.
|
|
199
|
+
return { mergeStatus: "review-findings" };
|
|
189
200
|
}
|
|
190
201
|
// The head the backstop verified — pinned through the land call so a push between this check
|
|
191
202
|
// and the merge can never land a later, unreviewed head.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// pr.reconverge (issue #939): findings on a head-current review hand the PR back to the ONE canonical
|
|
2
|
+
// convergence entry (`submitPr`), declaring the merge-loop instance as the hand-off source.
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
5
|
+
import { noopLog } from "../../test/log.ts";
|
|
6
|
+
import { makeHandler, type FetchPrHead, type Submit } from "./worker.ts";
|
|
7
|
+
|
|
8
|
+
test("re-submits through submitPr with auto-merge on and handoffFrom = this merge-loop instance", async () => {
|
|
9
|
+
const merges: Record<string, unknown>[] = [];
|
|
10
|
+
const app = {
|
|
11
|
+
data: {
|
|
12
|
+
table: (name: string) => ({
|
|
13
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 1 } : undefined),
|
|
14
|
+
insert: async (r: Record<string, unknown>) => (merges.push(r), 1),
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
engine: {},
|
|
18
|
+
log: noopLog(),
|
|
19
|
+
} as any;
|
|
20
|
+
let args: unknown[] = [];
|
|
21
|
+
const submit = (async (...a: unknown[]) => {
|
|
22
|
+
args = a;
|
|
23
|
+
return { prKey: "acme/w#7" };
|
|
24
|
+
}) as unknown as Submit;
|
|
25
|
+
await makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
26
|
+
assertEquals((args[2] as { prKey: string }).prKey, "acme/w#7");
|
|
27
|
+
assertEquals(args[7], false, "no re-gate on human approval");
|
|
28
|
+
assertEquals(args[8], true, "steward opt-in carried from the row");
|
|
29
|
+
assertEquals(args[9], true, "auto-merge stays on");
|
|
30
|
+
assertEquals(args[10], "4242", "hand-off source is this merge-loop instance");
|
|
31
|
+
assertEquals(merges[0].outcome, "reconverge");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Issue #941 review: `submitPr` can return `processKey: null` after rewriting the row to
|
|
35
|
+
// `converging` with no owner (a create-instance failure window). Completing the job successfully
|
|
36
|
+
// then ends the merge loop at `MergeReconverged`, leaving the PR with NO live convergence or merge
|
|
37
|
+
// instance to advance it. The worker must throw on a null process key so the job stays retryable
|
|
38
|
+
// instead of stranding the PR.
|
|
39
|
+
test("throws on a null processKey so the hand-off stays retryable (#941)", async () => {
|
|
40
|
+
const app = {
|
|
41
|
+
data: {
|
|
42
|
+
table: (name: string) => ({
|
|
43
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
|
|
44
|
+
insert: async () => 1,
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
engine: {},
|
|
48
|
+
log: noopLog(),
|
|
49
|
+
} as any;
|
|
50
|
+
const submit = (async () => ({ prKey: "acme/w#7", processKey: null })) as unknown as Submit;
|
|
51
|
+
const err = await assertRejects(
|
|
52
|
+
() => makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app),
|
|
53
|
+
Error,
|
|
54
|
+
"null processKey",
|
|
55
|
+
);
|
|
56
|
+
assertStringIncludes(err.message, "acme/w#7");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Issue #941 round-4 review (Copilot "previously missed", high): the world checkpoint is written
|
|
60
|
+
// ONLY by the convergence loop's `pr.persist-round` — a merge-stage rebase/fix-ci push advances the
|
|
61
|
+
// PR head WITHOUT updating the checkpoint. On a durable-resume fleet a reconverge hand-off that
|
|
62
|
+
// re-enters `submitPr` on the checkpoint therefore provisions the fresh convergence agent at the
|
|
63
|
+
// PRE-MERGE tree, not the head whose review raised the findings. The hand-off must carry the
|
|
64
|
+
// CURRENT PR head SHA as `submitPr`'s restore override.
|
|
65
|
+
test("hands off at the CURRENT PR head, not the stale convergence checkpoint (#941 round 4)", async () => {
|
|
66
|
+
const app = {
|
|
67
|
+
data: {
|
|
68
|
+
table: (name: string) => ({
|
|
69
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
|
|
70
|
+
insert: async () => 1,
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
engine: {},
|
|
74
|
+
log: noopLog(),
|
|
75
|
+
} as any;
|
|
76
|
+
let args: unknown[] = [];
|
|
77
|
+
const submit = (async (...a: unknown[]) => {
|
|
78
|
+
args = a;
|
|
79
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
80
|
+
}) as unknown as Submit;
|
|
81
|
+
const head = (async () => ({
|
|
82
|
+
headRef: "feat/x",
|
|
83
|
+
headSha: "b".repeat(40),
|
|
84
|
+
baseRef: "main",
|
|
85
|
+
headRepo: "acme/w",
|
|
86
|
+
})) as unknown as FetchPrHead;
|
|
87
|
+
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
88
|
+
assertEquals(args[11], "b".repeat(40), "the restore override is the live PR head SHA");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a head read that resolves no SHA degrades to the checkpoint restore (no override)", async () => {
|
|
92
|
+
const app = {
|
|
93
|
+
data: {
|
|
94
|
+
table: (name: string) => ({
|
|
95
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
|
|
96
|
+
insert: async () => 1,
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
engine: {},
|
|
100
|
+
log: noopLog(),
|
|
101
|
+
} as any;
|
|
102
|
+
let args: unknown[] = [];
|
|
103
|
+
const submit = (async (...a: unknown[]) => {
|
|
104
|
+
args = a;
|
|
105
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
106
|
+
}) as unknown as Submit;
|
|
107
|
+
const head = (async () => null) as unknown as FetchPrHead;
|
|
108
|
+
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
109
|
+
assertEquals(args[11], null, "no live head SHA → no override (checkpoint restore, the pre-fix behaviour)");
|
|
110
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// pr.reconverge — hand a PR back from the merge loop to the convergence loop (issue #939).
|
|
2
|
+
//
|
|
3
|
+
// Reached when `pr.merge`'s backstop finds a HEAD-CURRENT Copilot review whose own summary reports
|
|
4
|
+
// open findings (`mergeStatus: "review-findings"`) — typically the review the merge loop itself
|
|
5
|
+
// requested after a rebase / fix push. Actionable findings belong to the convergence path
|
|
6
|
+
// (fix → push → review), not to a human, so this re-submits the PR through the ONE canonical entry
|
|
7
|
+
// (`submitPr`), declaring this merge-loop instance as the hand-off source so the live row it owns is
|
|
8
|
+
// not misread as "already running". The merge loop then ends; the fresh convergence run starts a new
|
|
9
|
+
// merge loop on convergence, exactly as a first pass does. No parallel review loop is introduced.
|
|
10
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
11
|
+
import { fetchPrHead } from "../../app/github.ts";
|
|
12
|
+
import { parsePr } from "../../app/prParse.ts";
|
|
13
|
+
import { MAX_ROUNDS, submitPr } from "../../app/service.ts";
|
|
14
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
15
|
+
|
|
16
|
+
// Input typed off the model data envelope (`ReconvergeIn` in merge-loop.bpmn) — ADR 0040.
|
|
17
|
+
type In = WorkerInputs["pr.reconverge"];
|
|
18
|
+
|
|
19
|
+
export type Submit = typeof submitPr;
|
|
20
|
+
export type FetchPrHead = typeof fetchPrHead;
|
|
21
|
+
|
|
22
|
+
export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead): AppJobHandler<In> {
|
|
23
|
+
return async (job, app) => {
|
|
24
|
+
const { prKey } = job.variables;
|
|
25
|
+
const parsed = parsePr(prKey);
|
|
26
|
+
if (!parsed) throw new Error(`reconverge: unparseable prKey ${JSON.stringify(prKey)}`);
|
|
27
|
+
const row = await app.data.table("pull_requests", "pr_key").get(prKey);
|
|
28
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for the untyped row shape
|
|
29
|
+
const optIn = (row as { auto_resolve_escalations?: number } | undefined)?.auto_resolve_escalations === 1;
|
|
30
|
+
await app.data.table("merges", "id").insert({
|
|
31
|
+
pr_key: prKey,
|
|
32
|
+
outcome: "reconverge",
|
|
33
|
+
method: "review-backstop",
|
|
34
|
+
detail: "head-current Copilot review reports open findings — re-entering convergence",
|
|
35
|
+
at: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
// Head-current restore (#941 round-4 review): the durable world checkpoint is written only by
|
|
38
|
+
// the convergence loop's `pr.persist-round`, so a merge-stage rebase/fix-ci push — the very push
|
|
39
|
+
// whose head-current review sent us here — advances the PR head WITHOUT updating the checkpoint.
|
|
40
|
+
// Re-entering `submitPr` on the checkpoint would provision the fresh convergence agent at the
|
|
41
|
+
// PRE-MERGE tree, risking stale fixes or a rejected (non-fast-forward) push. Read the LIVE head
|
|
42
|
+
// SHA and pass it as the restore override so the hand-off resumes at the head the review saw.
|
|
43
|
+
// Best-effort: an unreadable head degrades to `null` (no override → the checkpoint restore, the
|
|
44
|
+
// pre-fix behaviour) rather than blocking the hand-off on a GitHub read.
|
|
45
|
+
let headSha: string | null = null;
|
|
46
|
+
try {
|
|
47
|
+
headSha = (await fetchHead(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""))?.headSha ?? null;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
app.log.warn("reconverge: head read failed — falling back to the checkpoint restore", { prKey, err: String(err) });
|
|
50
|
+
}
|
|
51
|
+
// The PR was already merging (merge intent on, any human approval already given), so the fresh
|
|
52
|
+
// run keeps auto-merge and does not re-gate on approval.
|
|
53
|
+
const result = await submit(
|
|
54
|
+
app.data,
|
|
55
|
+
app.engine,
|
|
56
|
+
parsed,
|
|
57
|
+
[],
|
|
58
|
+
MAX_ROUNDS,
|
|
59
|
+
false,
|
|
60
|
+
null,
|
|
61
|
+
false,
|
|
62
|
+
optIn,
|
|
63
|
+
true,
|
|
64
|
+
job.processInstanceKey != null ? String(job.processInstanceKey) : null,
|
|
65
|
+
headSha,
|
|
66
|
+
);
|
|
67
|
+
// `submitPr` can return `processKey: null` after rewriting the row to `converging` with no owner
|
|
68
|
+
// (the create-instance failure window). Completing HERE would end the merge loop at
|
|
69
|
+
// `MergeReconverged` with the PR left on a keyless `converging` row — no live convergence or
|
|
70
|
+
// merge instance to advance it. Throw instead so the job fails and stays RETRYABLE: the retry
|
|
71
|
+
// re-enters `submitPr`, which treats the keyless non-terminal row as resubmittable and re-enrolls
|
|
72
|
+
// it (the #704/#497 phantom-row recovery path).
|
|
73
|
+
if (result != null && typeof result === "object" && "processKey" in result && result.processKey == null) {
|
|
74
|
+
throw new Error(`reconverge: submitPr returned a null processKey for ${prKey} — no convergence instance was started; failing so the hand-off is retried`);
|
|
75
|
+
}
|
|
76
|
+
return {};
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handler = makeHandler(submitPr);
|
|
81
|
+
export default handler;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// pr.request-review (issue #939): the merge loop requests a head-current Copilot review itself, and
|
|
2
|
+
// re-triggers a silently-ignored request (withdraw + re-add) on a re-request.
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { assertEquals } from "#test-assert";
|
|
5
|
+
import { noopLog } from "../../test/log.ts";
|
|
6
|
+
import { makeHandler, type ReviewRequestOps } from "./worker.ts";
|
|
7
|
+
|
|
8
|
+
function fakeApp() {
|
|
9
|
+
const merges: Record<string, unknown>[] = [];
|
|
10
|
+
return {
|
|
11
|
+
merges,
|
|
12
|
+
app: {
|
|
13
|
+
data: { table: () => ({ insert: (r: Record<string, unknown>) => (merges.push(r), Promise.resolve(1)) }) },
|
|
14
|
+
log: noopLog(),
|
|
15
|
+
} as any,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ops(over: Partial<ReviewRequestOps>, calls: string[]): ReviewRequestOps {
|
|
20
|
+
return {
|
|
21
|
+
hasPending: async () => (calls.push("pending?"), false),
|
|
22
|
+
remove: async () => calls.push("remove"),
|
|
23
|
+
request: async () => (calls.push("request"), "requested"),
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const job = (round: number) => ({ variables: { prKey: "acme/w#1", repo: "acme/w", prNumber: 1, reviewRequestRound: round } }) as any;
|
|
29
|
+
|
|
30
|
+
test("first request POSTs the Copilot reviewer and audits it", async () => {
|
|
31
|
+
const calls: string[] = [];
|
|
32
|
+
const { app, merges } = fakeApp();
|
|
33
|
+
const out = await makeHandler(ops({}, calls))(job(0), app);
|
|
34
|
+
assertEquals(out, { reviewRequest: "requested" });
|
|
35
|
+
assertEquals(calls, ["request"]);
|
|
36
|
+
assertEquals(merges[0].outcome, "review-requested");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a re-request with Copilot still pending (silently ignored) withdraws then re-adds", async () => {
|
|
40
|
+
const calls: string[] = [];
|
|
41
|
+
const { app } = fakeApp();
|
|
42
|
+
await makeHandler(ops({ hasPending: async () => (calls.push("pending?"), true) }, calls))(job(1), app);
|
|
43
|
+
assertEquals(calls, ["pending?", "remove", "request"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("Copilot not assignable reports unavailable; a transport error reports failed (never throws)", async () => {
|
|
47
|
+
const { app } = fakeApp();
|
|
48
|
+
assertEquals(await makeHandler(ops({ request: async () => "unavailable" }, []))(job(0), app), { reviewRequest: "unavailable" });
|
|
49
|
+
assertEquals(
|
|
50
|
+
await makeHandler(ops({ request: async () => { throw new Error("502"); } }, []))(job(0), app),
|
|
51
|
+
{ reviewRequest: "failed" },
|
|
52
|
+
);
|
|
53
|
+
assertEquals(await makeHandler(ops({ request: async () => null }, []))(job(0), app), { reviewRequest: "failed" });
|
|
54
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// pr.request-review — the merge loop requests a HEAD-CURRENT Copilot review itself (issue #939).
|
|
2
|
+
//
|
|
3
|
+
// Reached when `pr.merge`'s backstop finds no head-current review (`mergeStatus: "review-pending"`):
|
|
4
|
+
// the loop's own rebase / merge-from-base / fix push moved the head, so the last review is stale.
|
|
5
|
+
// The only remedy is mechanical — request a review and wait — so the loop does it instead of
|
|
6
|
+
// escalating to a human. The model then waits on a bounded timer (`reviewRequestWait`) and re-arms
|
|
7
|
+
// the merge poller; `pr.merge` re-runs the backstop on the new head. Only after `reviewRequestMax`
|
|
8
|
+
// requests with no review (or when Copilot cannot be requested at all) does it escalate.
|
|
9
|
+
//
|
|
10
|
+
// "Silently ignored" requests: Copilot can stay listed as a pending reviewer yet never review, and a
|
|
11
|
+
// second POST is then a no-op. On a re-request (round > 0) with Copilot still pending, withdraw the
|
|
12
|
+
// request first and re-add it — the equivalent that re-triggers the review.
|
|
13
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
14
|
+
import { hasPendingCopilotReviewer, removeCopilotReviewer, requestCopilotReview } from "../../app/github.ts";
|
|
15
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
16
|
+
|
|
17
|
+
// Input typed off the model data envelope (`RequestReviewIn` in merge-loop.bpmn) — ADR 0040.
|
|
18
|
+
type In = WorkerInputs["pr.request-review"];
|
|
19
|
+
|
|
20
|
+
interface Out extends Record<string, unknown> {
|
|
21
|
+
/** `requested` — a request is in flight; `unavailable` — Copilot is not an assignable reviewer on
|
|
22
|
+
* this repo (waiting is pointless → escalate); `failed` — no usable transport / transport error
|
|
23
|
+
* (the bounded wait still re-checks, so a transient failure is retried by the budget). */
|
|
24
|
+
reviewRequest: "requested" | "unavailable" | "failed";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ReviewRequestOps {
|
|
28
|
+
hasPending: (repo: string, prNumber: number) => Promise<boolean | null>;
|
|
29
|
+
remove: (repo: string, prNumber: number) => Promise<unknown>;
|
|
30
|
+
request: (repo: string, prNumber: number) => Promise<"requested" | "unavailable" | null>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const token = () => process.env.GITHUB_TOKEN ?? "";
|
|
34
|
+
const defaultOps: ReviewRequestOps = {
|
|
35
|
+
hasPending: (repo, n) => hasPendingCopilotReviewer(repo, n, token()),
|
|
36
|
+
remove: (repo, n) => removeCopilotReviewer(repo, n, token()),
|
|
37
|
+
request: (repo, n) => requestCopilotReview(repo, n, token()),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function makeHandler(ops: ReviewRequestOps): AppJobHandler<In, Out> {
|
|
41
|
+
return async (job, app) => {
|
|
42
|
+
const { prKey, repo, prNumber, reviewRequestRound } = job.variables;
|
|
43
|
+
const round = typeof reviewRequestRound === "number" ? reviewRequestRound : 0;
|
|
44
|
+
let result: Out["reviewRequest"];
|
|
45
|
+
try {
|
|
46
|
+
// A re-request whose previous ask is still pending was silently ignored → withdraw, re-add.
|
|
47
|
+
if (round > 0 && (await ops.hasPending(repo, prNumber)) === true) {
|
|
48
|
+
await ops.remove(repo, prNumber);
|
|
49
|
+
}
|
|
50
|
+
const res = await ops.request(repo, prNumber);
|
|
51
|
+
result = res === "requested" ? "requested" : res === "unavailable" ? "unavailable" : "failed";
|
|
52
|
+
} catch (err) {
|
|
53
|
+
app.log.warn(`request-review: ${prKey} Copilot review request failed: ${err}`);
|
|
54
|
+
result = "failed";
|
|
55
|
+
}
|
|
56
|
+
await app.data.table("merges", "id").insert({
|
|
57
|
+
pr_key: prKey,
|
|
58
|
+
outcome: "review-requested",
|
|
59
|
+
method: "review-request",
|
|
60
|
+
detail: `head-current Copilot review request #${round + 1}: ${result}`,
|
|
61
|
+
at: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
return { reviewRequest: result };
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handler = makeHandler(defaultOps);
|
|
68
|
+
export default handler;
|