@nanobpm/nano-workforce 0.205.0 → 0.205.1
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 +6 -0
- package/app/github.test.ts +285 -1
- package/app/github.ts +160 -8
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +129 -0
- package/app/readiness.ts +1 -0
- package/app/service.ts +139 -1
- package/app/userTasks.ts +1 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +55 -9
- package/workers/merge-stall-probe/worker.ts +17 -3
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// #921: a PR whose merge is BLOCKED solely by unresolved review threads (branch protection
|
|
2
|
+
// `required_conversation_resolution`; `mergeable=MERGEABLE`, `mergeStateStatus=BLOCKED`, all
|
|
3
|
+
// required checks green) must be CLASSIFIED as such and routed back into convergence — never
|
|
4
|
+
// escalated as "GitHub never settled this PR's mergeability". These are poller-level tests: they
|
|
5
|
+
// drive `pollMerges` over a `waiting_merge` PR row against a stubbed GitHub transport serving the
|
|
6
|
+
// PR view + the GraphQL review-threads probe, and assert:
|
|
7
|
+
// • the verdict parks the PR (no `merge-ready` publish — the merge step is gated on zero
|
|
8
|
+
// unresolved threads);
|
|
9
|
+
// • once the threads resolve, the next pass publishes `merge-ready {ready}` and the merge
|
|
10
|
+
// proceeds with no human in the loop;
|
|
11
|
+
// • a `review-threads` verdict hands the PR DURABLY back into convergence (PR #922 review): the
|
|
12
|
+
// row flips to `waiting_review` and a fresh convergence instance is started, so the
|
|
13
|
+
// review-fix agent actually activates on the open threads — a bare re-review request leaves
|
|
14
|
+
// the PR in `waiting_merge`, which `pollReviews` never scans.
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { assert, assertEquals } from "#test-assert";
|
|
20
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
21
|
+
import { pollMerges } from "./service.ts";
|
|
22
|
+
|
|
23
|
+
/** A single-page GraphQL review-threads payload with `n` unresolved threads, for the gh stub. */
|
|
24
|
+
function graphqlThreads(n: number): string {
|
|
25
|
+
const threads = Array.from({ length: n }, (_, i) => ({
|
|
26
|
+
isResolved: false,
|
|
27
|
+
path: `f${i}.ts`,
|
|
28
|
+
comments: { nodes: [{ body: `thread ${i}` }] },
|
|
29
|
+
}));
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
data: {
|
|
32
|
+
repository: {
|
|
33
|
+
pullRequest: { reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: threads } },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
40
|
+
const stores: Record<string, any[]> = {};
|
|
41
|
+
function tbl(name: string, pk = "id") {
|
|
42
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
43
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
44
|
+
return {
|
|
45
|
+
async all() {
|
|
46
|
+
return rows.slice();
|
|
47
|
+
},
|
|
48
|
+
async get(id: any) {
|
|
49
|
+
return rows.find((r) => r[pk] === id);
|
|
50
|
+
},
|
|
51
|
+
async find(where: any = {}) {
|
|
52
|
+
return rows.filter((r) => match(r, where));
|
|
53
|
+
},
|
|
54
|
+
async insert(row: any) {
|
|
55
|
+
rows.push({ ...row });
|
|
56
|
+
return row[pk];
|
|
57
|
+
},
|
|
58
|
+
async update(id: any, patch: any) {
|
|
59
|
+
const r = rows.find((row) => row[pk] === id);
|
|
60
|
+
if (r) Object.assign(r, patch);
|
|
61
|
+
},
|
|
62
|
+
async delete(id: any) {
|
|
63
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// `data.open()` is the raw-statement escape hatch a few reconcilers use. The in-memory stub has
|
|
68
|
+
// no SQL store, so apply the one statement this suite's domain flow issues — `submitPr`'s re-open
|
|
69
|
+
// path runs `resetAdjudications` (`DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`) — against
|
|
70
|
+
// the in-memory rows, so the handoff test below genuinely observes the adjudication reset rather
|
|
71
|
+
// than a no-op. Any other statement throws loudly instead of silently passing.
|
|
72
|
+
const data = {
|
|
73
|
+
table: (n: string, pk?: string) => tbl(n, pk),
|
|
74
|
+
open: () => ({
|
|
75
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
76
|
+
const m = sql.match(/^\s*DELETE\s+FROM\s+"?(\w+)"?\s+WHERE\s+"?(\w+)"?\s*=\s*\?\s*$/i);
|
|
77
|
+
if (!m) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
|
|
78
|
+
const rows = (stores[m[1]] ??= [] as any[]);
|
|
79
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][m[2]] === params[0]) rows.splice(i, 1);
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
} as any as DataLayer;
|
|
83
|
+
return { data, stores };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function recordingEngine(): { engine: EngineClient; messages: any[]; cancelled: string[] } {
|
|
87
|
+
const messages: any[] = [];
|
|
88
|
+
const cancelled: string[] = [];
|
|
89
|
+
const engine = {
|
|
90
|
+
async publishMessage(msg: any) {
|
|
91
|
+
messages.push(msg);
|
|
92
|
+
},
|
|
93
|
+
async cancelInstance(args: { processInstanceKey: string }) {
|
|
94
|
+
cancelled.push(String(args.processInstanceKey));
|
|
95
|
+
},
|
|
96
|
+
} as any as EngineClient;
|
|
97
|
+
return { engine, messages, cancelled };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A token-transport GitHub stub serving the REST PR view (`mergeable_state: "blocked"`, the
|
|
101
|
+
// conversation-resolution block), the GraphQL review-threads probe (`unresolved` open threads),
|
|
102
|
+
// and the REST reviewers POST (the Copilot re-request nudge). `failingChecks` is -1 in token mode
|
|
103
|
+
// …which would keep the classifier conservative — so these tests ALSO stub the check rollup via
|
|
104
|
+
// the GraphQL threads response only, and rely on the REST view. To keep the classifier out of
|
|
105
|
+
// token mode's "can't enumerate checks" branch, the stub serves a REST `check-runs` free PR whose
|
|
106
|
+
// merge block is purely the threads — but the REST single-PR view never enumerates checks, so the
|
|
107
|
+
// classifier reads `failingChecks: -1`. That is exactly the production shape on a token-transport
|
|
108
|
+
// deploy; the `review-threads` verdict therefore requires the gh transport in production. These
|
|
109
|
+
// tests pin the TOKEN-transport behaviour (conservative: park + nudge + never escalate) and the
|
|
110
|
+
// classifier-level verdict is covered by the gh-mode unit tests in app/github.test.ts.
|
|
111
|
+
interface Fixture {
|
|
112
|
+
unresolved: number; // threads with isResolved=false
|
|
113
|
+
mergeableState?: string;
|
|
114
|
+
}
|
|
115
|
+
function githubFetch(fx: Fixture, calls: { reviewPosts: number }) {
|
|
116
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
117
|
+
const u = new URL(String(url));
|
|
118
|
+
const json = (obj: unknown, status = 200) =>
|
|
119
|
+
Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
|
|
120
|
+
if (u.pathname === "/graphql") {
|
|
121
|
+
const threads = Array.from({ length: fx.unresolved }, (_, i) => ({
|
|
122
|
+
isResolved: false,
|
|
123
|
+
path: `f${i}.ts`,
|
|
124
|
+
comments: { nodes: [{ body: `thread ${i}` }] },
|
|
125
|
+
}));
|
|
126
|
+
return json({
|
|
127
|
+
data: {
|
|
128
|
+
repository: {
|
|
129
|
+
pullRequest: { reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: threads } },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (u.pathname.endsWith("/requested_reviewers") && init?.method === "POST") {
|
|
135
|
+
calls.reviewPosts += 1;
|
|
136
|
+
return json({}, 201);
|
|
137
|
+
}
|
|
138
|
+
const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
|
|
139
|
+
if (m) {
|
|
140
|
+
return json({
|
|
141
|
+
merged: false,
|
|
142
|
+
merged_at: null,
|
|
143
|
+
state: "open",
|
|
144
|
+
mergeable_state: fx.mergeableState ?? "blocked",
|
|
145
|
+
draft: false,
|
|
146
|
+
head: { sha: "deadbeef" },
|
|
147
|
+
base: { ref: "main" },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function withGithub<T>(fx: Fixture, calls: { reviewPosts: number }, fn: () => Promise<T>): Promise<T> {
|
|
155
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
156
|
+
const prevFetch = globalThis.fetch;
|
|
157
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
158
|
+
globalThis.fetch = githubFetch(fx, calls) as typeof fetch;
|
|
159
|
+
try {
|
|
160
|
+
return await fn();
|
|
161
|
+
} finally {
|
|
162
|
+
globalThis.fetch = prevFetch;
|
|
163
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
164
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function waitingMergeRow(prKey: string, number: number) {
|
|
169
|
+
const ts = "2026-08-20T00:00:00Z";
|
|
170
|
+
return {
|
|
171
|
+
pr_key: prKey,
|
|
172
|
+
repo: "o/r",
|
|
173
|
+
number,
|
|
174
|
+
url: `https://github.com/o/r/pull/${number}`,
|
|
175
|
+
title: "t",
|
|
176
|
+
status: "waiting_merge",
|
|
177
|
+
current_round: 0,
|
|
178
|
+
// A real parked merge wait ALWAYS carries a live `process_key` (startMerge/arm-merge set it after
|
|
179
|
+
// the merge instance is created). `null` is the UNFINISHED-handoff marker the dead-owner guard keys
|
|
180
|
+
// on, so the realistic default here is a live key — the ownerless-handoff test overrides it to null.
|
|
181
|
+
process_key: "merge-live-1",
|
|
182
|
+
waiting_since: null,
|
|
183
|
+
last_review_id: null,
|
|
184
|
+
outcome: null,
|
|
185
|
+
created_at: ts,
|
|
186
|
+
updated_at: ts,
|
|
187
|
+
converged_at: null,
|
|
188
|
+
merged_at: null,
|
|
189
|
+
fresh_head_run_head: null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
test("#921 poller: a threads-BLOCKED PR parks (no merge-ready) — the merge stays gated on resolution", async () => {
|
|
194
|
+
// Token transport can't enumerate checks, so the classifier stays conservative and the verdict
|
|
195
|
+
// is "waiting" even with threads probed — the PR must NOT advance to attempt-merge, must NOT
|
|
196
|
+
// escalate, and must be re-read next pass. (The gh-transport `review-threads` verdict is pinned
|
|
197
|
+
// in app/github.test.ts; the merge-loop routing of that verdict in app/mergeLoopBehaviour.test.ts.)
|
|
198
|
+
const { data, stores } = memData();
|
|
199
|
+
const { engine, messages } = recordingEngine();
|
|
200
|
+
stores["pull_requests"] = [waitingMergeRow("o/r#100", 100)];
|
|
201
|
+
const calls = { reviewPosts: 0 };
|
|
202
|
+
await withGithub({ unresolved: 2 }, calls, async () => {
|
|
203
|
+
await pollMerges(data, engine, "tok");
|
|
204
|
+
});
|
|
205
|
+
assertEquals(
|
|
206
|
+
messages.filter((m) => m.name === "merge-ready").length,
|
|
207
|
+
0,
|
|
208
|
+
"a threads-BLOCKED PR must not publish merge-ready — the merge step is gated on zero unresolved threads",
|
|
209
|
+
);
|
|
210
|
+
assertEquals(stores["pull_requests"][0].status, "waiting_merge", "the PR stays parked for the next pass");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("#921 poller: once the threads resolve the next pass publishes merge-ready and the merge proceeds", async () => {
|
|
214
|
+
const { data, stores } = memData();
|
|
215
|
+
const { engine, messages } = recordingEngine();
|
|
216
|
+
stores["pull_requests"] = [waitingMergeRow("o/r#100", 100)];
|
|
217
|
+
const calls = { reviewPosts: 0 };
|
|
218
|
+
// Threads resolved (none open) and the branch otherwise mergeable → the gate lifts.
|
|
219
|
+
await withGithub({ unresolved: 0, mergeableState: "clean" }, calls, async () => {
|
|
220
|
+
await pollMerges(data, engine, "tok");
|
|
221
|
+
});
|
|
222
|
+
const ready = messages.filter((m) => m.name === "merge-ready");
|
|
223
|
+
assertEquals(ready.length, 1, "a clean PR with zero unresolved threads must advance to the merge step");
|
|
224
|
+
assertEquals(ready[0].variables.mergeState, "ready");
|
|
225
|
+
assertEquals(ready[0].variables.reviewThreads, 0, "the payload carries the probed thread count");
|
|
226
|
+
assertEquals(ready[0].variables.pendingChecks, 0, "the payload carries the pending-check count (token mode: unenumerable → 0)");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("#921/PR#922 poller: a 'review-threads' verdict hands the PR back into convergence — the remediation agent actually activates", async () => {
|
|
230
|
+
// gh transport: checks enumerable and green, merge BLOCKED, two unresolved threads → the settled
|
|
231
|
+
// `review-threads` verdict. The poller must NOT merely request another Copilot review (that
|
|
232
|
+
// leaves the PR in `waiting_merge`, which `pollReviews` never scans — no review-fix agent would
|
|
233
|
+
// ever activate); it re-enters the PR into the canonical convergence loop: the row flips to
|
|
234
|
+
// `waiting_review` and a FRESH convergence instance is created.
|
|
235
|
+
//
|
|
236
|
+
// The row's `process_key` names the LIVE merge instance (the realistic merge-stage shape). The
|
|
237
|
+
// handoff must RETIRE it first (PR #922 review, thread 3): left running, `submitPr`'s idempotency
|
|
238
|
+
// gate would classify the PR `alreadyRunning` and start NOTHING — no convergence instance would
|
|
239
|
+
// ever launch and the old merge timer would keep running. Cancelling it flips the derived
|
|
240
|
+
// tracking status terminal, so the gate falls through and the fresh convergence run takes over.
|
|
241
|
+
const { data, stores } = memData();
|
|
242
|
+
const instances: any[] = [];
|
|
243
|
+
const { engine, cancelled } = recordingEngine();
|
|
244
|
+
(engine as any).createInstance = async (args: any) => {
|
|
245
|
+
instances.push(args);
|
|
246
|
+
return { processInstanceKey: "conv-1" };
|
|
247
|
+
};
|
|
248
|
+
const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: "merge-1", auto_resolve_escalations: 1 };
|
|
249
|
+
stores["pull_requests"] = [mergeRow];
|
|
250
|
+
// The derived tracking VIEW the idempotency gate reads: the merge instance is LIVE (non-terminal)
|
|
251
|
+
// until the handoff's `cancelInstance` retires it. The stub folds the row to a terminal edge
|
|
252
|
+
// (`abandoned`) once its `process_key` is cancelled, mirroring the ADR-0065 reconciler.
|
|
253
|
+
stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
|
|
254
|
+
(engine as any).cancelInstance = async (args: { processInstanceKey: string }) => {
|
|
255
|
+
cancelled.push(String(args.processInstanceKey));
|
|
256
|
+
for (const t of stores["pull_requests__tracking"]) {
|
|
257
|
+
if (t.process_key === String(args.processInstanceKey)) t.derived_status = "abandoned";
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
// A stale adjudication from the prior convergence run must be wiped by the re-open, so the fresh
|
|
261
|
+
// run never replays a prior decision (issue #806 reset, exercised through the handoff path).
|
|
262
|
+
stores["pr_adjudications"] = [{ pr_key: "o/r#100", question: "q", answer: "a" }];
|
|
263
|
+
const calls = { reviewPosts: 0 };
|
|
264
|
+
// The gh transport runs the `gh` CLI; intercept it with a stub on PATH that serves the PR view
|
|
265
|
+
// (checks green, merge blocked) and the GraphQL review-threads probe from the fixture.
|
|
266
|
+
const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
|
|
267
|
+
const script = `#!/usr/bin/env bash
|
|
268
|
+
set -euo pipefail
|
|
269
|
+
if [[ "$*" == *"pr view"* ]]; then
|
|
270
|
+
echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"BLOCKED","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
|
|
271
|
+
elif [[ "$*" == *"graphql"* ]]; then
|
|
272
|
+
echo '${graphqlThreads(2)}'
|
|
273
|
+
elif [[ "$*" == *"requested_reviewers"* ]]; then
|
|
274
|
+
exit 0
|
|
275
|
+
else
|
|
276
|
+
echo "unexpected gh args: $*" >&2
|
|
277
|
+
exit 1
|
|
278
|
+
fi
|
|
279
|
+
`;
|
|
280
|
+
await writeFile(join(bin, "gh"), script, { mode: 0o755 });
|
|
281
|
+
const prevPath = process.env.PATH;
|
|
282
|
+
const prevFetch = globalThis.fetch;
|
|
283
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
284
|
+
process.env.PATH = `${bin}:${prevPath ?? ""}`;
|
|
285
|
+
// Pin the gh transport: `auto` would work too (the PATH stub answers the availability probe),
|
|
286
|
+
// but the explicit mode is immune to the memoized probe result leaking in from another test file.
|
|
287
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
|
|
288
|
+
globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
|
|
289
|
+
try {
|
|
290
|
+
await pollMerges(data, engine, "tok");
|
|
291
|
+
} finally {
|
|
292
|
+
globalThis.fetch = prevFetch;
|
|
293
|
+
if (prevPath === undefined) delete process.env.PATH;
|
|
294
|
+
else process.env.PATH = prevPath;
|
|
295
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
296
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
297
|
+
await rm(bin, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
const row = stores["pull_requests"][0];
|
|
300
|
+
assertEquals(cancelled, ["merge-1"], "the live merge instance is retired before the re-open");
|
|
301
|
+
assertEquals(row.status, "converging", "the re-opened PR row presents the fresh convergence run");
|
|
302
|
+
assertEquals(row.process_key, "conv-1", "the row is re-pointed at the fresh convergence instance");
|
|
303
|
+
assertEquals(instances.length, 1, "exactly one fresh convergence instance is started");
|
|
304
|
+
assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered");
|
|
305
|
+
assertEquals(instances[0].variables.prKey, "o/r#100");
|
|
306
|
+
assertEquals(instances[0].variables.round, 1, "the fresh run restarts at round 1");
|
|
307
|
+
assertEquals(calls.reviewPosts, 0, "no bare re-review request — the handoff, not a nudge, drives remediation");
|
|
308
|
+
assertEquals(stores["pr_adjudications"].length, 0, "the re-open wipes the prior run's adjudication memory (#806)");
|
|
309
|
+
// PR #922 review (the "previously missed" advisory): the handoff is an INTERNAL resubmission, so it
|
|
310
|
+
// must carry the row's existing escalation-steward opt-in through — a PR enrolled with automated
|
|
311
|
+
// escalation resolution must not silently lose that behaviour merely because late review threads
|
|
312
|
+
// appeared. `submitPr` defaults `autoResolveEscalations` to false and would overwrite the row to 0;
|
|
313
|
+
// the handoff passes the row's existing opt-in (as the feature/plan enrollment paths already do).
|
|
314
|
+
assertEquals(row.auto_resolve_escalations, 1, "the handoff preserves the escalation-steward opt-in on the row");
|
|
315
|
+
assertEquals(
|
|
316
|
+
instances[0].variables.autoResolveEscalations,
|
|
317
|
+
true,
|
|
318
|
+
"the fresh convergence instance carries the escalation-steward opt-in",
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("#921/PR#922 poller: a FAILED convergence handoff stays retryable — the row returns to waiting_merge, not a stranded waiting_review (PR #922 review, thread 3)", async () => {
|
|
323
|
+
// If the merge-instance cancel SUCCEEDS but `submitPr` then fails to create the replacement
|
|
324
|
+
// convergence instance, the row must NOT be left in `waiting_review` carrying the cancelled merge
|
|
325
|
+
// key: `pollMerges` no longer scans a `waiting_review` row, and `pollReviews` can only RESUME a
|
|
326
|
+
// live instance (its `readiness-ready` message correlates to nothing once the merge instance is
|
|
327
|
+
// cancelled and no fresh one exists), so the row would wedge with no handoff retry and no live
|
|
328
|
+
// review-timeout timer. The handoff must instead leave the row RETRYABLE — back in `waiting_merge`
|
|
329
|
+
// — so the next pass re-attempts the handoff against the already-retired (terminal) merge key.
|
|
330
|
+
const { data, stores } = memData();
|
|
331
|
+
const { engine, cancelled } = recordingEngine();
|
|
332
|
+
const instances: any[] = [];
|
|
333
|
+
// `submitPr` fails on the FIRST pass: the convergence instance cannot be created. A second pass
|
|
334
|
+
// (below) succeeds once the engine recovers.
|
|
335
|
+
let failCreate = true;
|
|
336
|
+
(engine as any).createInstance = async (args: any) => {
|
|
337
|
+
if (failCreate) throw new Error("engine unavailable");
|
|
338
|
+
instances.push(args);
|
|
339
|
+
return { processInstanceKey: "conv-retry-1" };
|
|
340
|
+
};
|
|
341
|
+
const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: "merge-1" };
|
|
342
|
+
stores["pull_requests"] = [mergeRow];
|
|
343
|
+
// The derived tracking VIEW: the merge instance folds to a terminal edge once cancelled.
|
|
344
|
+
stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
|
|
345
|
+
(engine as any).cancelInstance = async (args: { processInstanceKey: string }) => {
|
|
346
|
+
cancelled.push(String(args.processInstanceKey));
|
|
347
|
+
for (const t of stores["pull_requests__tracking"]) {
|
|
348
|
+
if (t.process_key === String(args.processInstanceKey)) t.derived_status = "abandoned";
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const calls = { reviewPosts: 0 };
|
|
352
|
+
const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
|
|
353
|
+
const script = `#!/usr/bin/env bash
|
|
354
|
+
set -euo pipefail
|
|
355
|
+
if [[ "$*" == *"pr view"* ]]; then
|
|
356
|
+
echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"BLOCKED","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
|
|
357
|
+
elif [[ "$*" == *"graphql"* ]]; then
|
|
358
|
+
echo '${graphqlThreads(2)}'
|
|
359
|
+
elif [[ "$*" == *"requested_reviewers"* ]]; then
|
|
360
|
+
exit 0
|
|
361
|
+
else
|
|
362
|
+
echo "unexpected gh args: $*" >&2
|
|
363
|
+
exit 1
|
|
364
|
+
fi
|
|
365
|
+
`;
|
|
366
|
+
await writeFile(join(bin, "gh"), script, { mode: 0o755 });
|
|
367
|
+
const prevPath = process.env.PATH;
|
|
368
|
+
const prevFetch = globalThis.fetch;
|
|
369
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
370
|
+
process.env.PATH = `${bin}:${prevPath ?? ""}`;
|
|
371
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
|
|
372
|
+
globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
|
|
373
|
+
try {
|
|
374
|
+
// Pass 1: the handoff fails (createInstance throws) AFTER the merge instance is retired.
|
|
375
|
+
await pollMerges(data, engine, "tok");
|
|
376
|
+
const rowAfterFail = stores["pull_requests"][0];
|
|
377
|
+
assertEquals(cancelled, ["merge-1"], "the live merge instance is still retired");
|
|
378
|
+
assertEquals(
|
|
379
|
+
rowAfterFail.status,
|
|
380
|
+
"waiting_merge",
|
|
381
|
+
"a failed handoff leaves the row retryable in waiting_merge (re-scanned next pass), not stranded in waiting_review",
|
|
382
|
+
);
|
|
383
|
+
assertEquals(instances.length, 0, "no convergence instance was created on the failed pass");
|
|
384
|
+
|
|
385
|
+
// Pass 2: the engine recovers. The retry re-derives the `review-threads` verdict and re-attempts
|
|
386
|
+
// the handoff; the merge key is already terminal, so the idempotency gate falls through and the
|
|
387
|
+
// fresh convergence instance starts — the PR is NOT wedged.
|
|
388
|
+
failCreate = false;
|
|
389
|
+
await pollMerges(data, engine, "tok");
|
|
390
|
+
} finally {
|
|
391
|
+
globalThis.fetch = prevFetch;
|
|
392
|
+
if (prevPath === undefined) delete process.env.PATH;
|
|
393
|
+
else process.env.PATH = prevPath;
|
|
394
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
395
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
396
|
+
await rm(bin, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
const row = stores["pull_requests"][0];
|
|
399
|
+
assertEquals(instances.length, 1, "the retry starts the fresh convergence instance");
|
|
400
|
+
assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered on retry");
|
|
401
|
+
assertEquals(row.status, "converging", "the retried handoff completes — the row presents the fresh convergence run");
|
|
402
|
+
assertEquals(row.process_key, "conv-retry-1", "the row is re-pointed at the fresh convergence instance");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("#921/PR#922 poller: an UNFINISHED handoff (dead merge instance) never publishes merge-ready to the cancelled owner — it re-establishes a live convergence owner first (PR #922 review, round 5)", async () => {
|
|
406
|
+
// The round-4 retry-restore leaves a failed handoff's row back in `waiting_merge` — but its
|
|
407
|
+
// `process_key` still names the CANCELLED merge instance (derived_status `abandoned`). If the next
|
|
408
|
+
// pass's GitHub verdict has moved off `review-threads` (the threads were resolved out-of-band, so the
|
|
409
|
+
// PR now reads `ready`), the verdict switch would fall through to `flipToMergingThenPublish` and
|
|
410
|
+
// publish `merge-ready {ready}` correlated to the DEAD merge instance — the message is dropped and the
|
|
411
|
+
// row wedges at `merging` with no live owner. The handoff must instead be persisted INDEPENDENTLY of
|
|
412
|
+
// the current verdict: a `waiting_merge` row whose merge instance is already terminal has no live
|
|
413
|
+
// merge-loop to signal, so the poller must re-establish a live convergence owner (re-open via
|
|
414
|
+
// `submitPr`) BEFORE any merge message is contemplated.
|
|
415
|
+
const { data, stores } = memData();
|
|
416
|
+
const { engine, messages, cancelled } = recordingEngine();
|
|
417
|
+
const instances: any[] = [];
|
|
418
|
+
(engine as any).createInstance = async (args: any) => {
|
|
419
|
+
instances.push(args);
|
|
420
|
+
return { processInstanceKey: "conv-fresh-1" };
|
|
421
|
+
};
|
|
422
|
+
// The unfinished-handoff shape: a prior pass's handoff retired the merge instance and its `submitPr`
|
|
423
|
+
// failed, so the restore left the row in `waiting_merge` with `process_key` NULL — no live owner.
|
|
424
|
+
const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: null };
|
|
425
|
+
stores["pull_requests"] = [mergeRow];
|
|
426
|
+
stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
|
|
427
|
+
const calls = { reviewPosts: 0 };
|
|
428
|
+
const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
|
|
429
|
+
// A clean READY verdict: MERGEABLE, all checks green, ZERO unresolved threads — the threads that
|
|
430
|
+
// triggered the original handoff were resolved out-of-band. This is the verdict that would send the
|
|
431
|
+
// PR through the normal `ready` branch and publish to the dead owner.
|
|
432
|
+
const script = `#!/usr/bin/env bash
|
|
433
|
+
set -euo pipefail
|
|
434
|
+
if [[ "$*" == *"pr view"* ]]; then
|
|
435
|
+
echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"CLEAN","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
|
|
436
|
+
elif [[ "$*" == *"graphql"* ]]; then
|
|
437
|
+
echo '${graphqlThreads(0)}'
|
|
438
|
+
elif [[ "$*" == *"requested_reviewers"* ]]; then
|
|
439
|
+
exit 0
|
|
440
|
+
else
|
|
441
|
+
echo "unexpected gh args: $*" >&2
|
|
442
|
+
exit 1
|
|
443
|
+
fi
|
|
444
|
+
`;
|
|
445
|
+
await writeFile(join(bin, "gh"), script, { mode: 0o755 });
|
|
446
|
+
const prevPath = process.env.PATH;
|
|
447
|
+
const prevFetch = globalThis.fetch;
|
|
448
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
449
|
+
process.env.PATH = `${bin}:${prevPath ?? ""}`;
|
|
450
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
|
|
451
|
+
globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
|
|
452
|
+
try {
|
|
453
|
+
await pollMerges(data, engine, "tok");
|
|
454
|
+
} finally {
|
|
455
|
+
globalThis.fetch = prevFetch;
|
|
456
|
+
if (prevPath === undefined) delete process.env.PATH;
|
|
457
|
+
else process.env.PATH = prevPath;
|
|
458
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
459
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
460
|
+
await rm(bin, { recursive: true, force: true });
|
|
461
|
+
}
|
|
462
|
+
const row = stores["pull_requests"][0];
|
|
463
|
+
assertEquals(
|
|
464
|
+
messages.filter((m) => m.name === "merge-ready").length,
|
|
465
|
+
0,
|
|
466
|
+
"an unfinished handoff must NOT publish merge-ready to the cancelled merge instance — the message would be dropped and the row wedged at merging",
|
|
467
|
+
);
|
|
468
|
+
assertEquals(
|
|
469
|
+
instances.length,
|
|
470
|
+
1,
|
|
471
|
+
"the poller re-establishes a live convergence owner before any merge message, independently of the ready verdict",
|
|
472
|
+
);
|
|
473
|
+
assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered");
|
|
474
|
+
assertEquals(row.process_key, "conv-fresh-1", "the row is re-pointed at the fresh live convergence instance");
|
|
475
|
+
assertEquals(row.status, "converging", "the row presents the fresh convergence run, not a wedged merging");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("#921/PR#922 poller: a HUMAN-CANCELLED merge-stage PR is NOT resurrected — the dead-owner guard keys on the NULL process_key, not the terminal derived_status", async () => {
|
|
479
|
+
// Anti-regression for the round-5 guard: a human cancel of a parked merge wait terminates the merge
|
|
480
|
+
// instance, so the row keeps base `status: "waiting_merge"` (the reconciler no longer writes the
|
|
481
|
+
// terminal edge onto the base row) and `process_key` STILL naming the terminated instance, while the
|
|
482
|
+
// derived VIEW folds to `abandoned`. That shape must NOT be re-opened into convergence — a cancelled
|
|
483
|
+
// run stays cancelled (the abandon protocol exists precisely to stop orphaned side effects). The guard
|
|
484
|
+
// therefore keys on `process_key === null` (the unfinished-handoff marker the restore writes), NOT on
|
|
485
|
+
// the terminal `derived_status` — which a human cancel shares. This row keeps `process_key` set, so the
|
|
486
|
+
// guard must not fire and no convergence instance may be created.
|
|
487
|
+
const { data, stores } = memData();
|
|
488
|
+
const { engine, messages } = recordingEngine();
|
|
489
|
+
const instances: any[] = [];
|
|
490
|
+
(engine as any).createInstance = async (args: any) => {
|
|
491
|
+
instances.push(args);
|
|
492
|
+
return { processInstanceKey: "conv-should-not-start" };
|
|
493
|
+
};
|
|
494
|
+
// Human-cancelled shape: base waiting_merge, process_key STILL set (names the terminated instance),
|
|
495
|
+
// derived_status folded terminal. Distinct from the unfinished handoff (which NULLs process_key).
|
|
496
|
+
const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: "merge-cancelled-by-human" };
|
|
497
|
+
stores["pull_requests"] = [mergeRow];
|
|
498
|
+
stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "abandoned" }];
|
|
499
|
+
const calls = { reviewPosts: 0 };
|
|
500
|
+
const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
|
|
501
|
+
const script = `#!/usr/bin/env bash
|
|
502
|
+
set -euo pipefail
|
|
503
|
+
if [[ "$*" == *"pr view"* ]]; then
|
|
504
|
+
echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"CLEAN","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
|
|
505
|
+
elif [[ "$*" == *"graphql"* ]]; then
|
|
506
|
+
echo '${graphqlThreads(0)}'
|
|
507
|
+
elif [[ "$*" == *"requested_reviewers"* ]]; then
|
|
508
|
+
exit 0
|
|
509
|
+
else
|
|
510
|
+
echo "unexpected gh args: $*" >&2
|
|
511
|
+
exit 1
|
|
512
|
+
fi
|
|
513
|
+
`;
|
|
514
|
+
await writeFile(join(bin, "gh"), script, { mode: 0o755 });
|
|
515
|
+
const prevPath = process.env.PATH;
|
|
516
|
+
const prevFetch = globalThis.fetch;
|
|
517
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
518
|
+
process.env.PATH = `${bin}:${prevPath ?? ""}`;
|
|
519
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
|
|
520
|
+
globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
|
|
521
|
+
try {
|
|
522
|
+
await pollMerges(data, engine, "tok");
|
|
523
|
+
} finally {
|
|
524
|
+
globalThis.fetch = prevFetch;
|
|
525
|
+
if (prevPath === undefined) delete process.env.PATH;
|
|
526
|
+
else process.env.PATH = prevPath;
|
|
527
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
528
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
529
|
+
await rm(bin, { recursive: true, force: true });
|
|
530
|
+
}
|
|
531
|
+
assertEquals(
|
|
532
|
+
instances.length,
|
|
533
|
+
0,
|
|
534
|
+
"a human-cancelled merge-stage PR must NOT be resurrected into convergence — its process_key still names the terminated instance, so the dead-owner guard does not fire",
|
|
535
|
+
);
|
|
536
|
+
assertEquals(
|
|
537
|
+
messages.filter((m) => m.name === "merge-ready").length,
|
|
538
|
+
0,
|
|
539
|
+
"no merge-ready is published to the cancelled (dead) merge instance — publishing it would be dropped and wedge the row at merging",
|
|
540
|
+
);
|
|
541
|
+
assertEquals(
|
|
542
|
+
stores["pull_requests"][0].process_key,
|
|
543
|
+
"merge-cancelled-by-human",
|
|
544
|
+
"the cancelled row's process_key is left untouched (not nulled, not re-pointed)",
|
|
545
|
+
);
|
|
546
|
+
assertEquals(
|
|
547
|
+
stores["pull_requests"][0].status,
|
|
548
|
+
"waiting_merge",
|
|
549
|
+
"the cancelled row is left in place (not flipped to the transient merging) — the abandon terminal edge is derived, not poller-written",
|
|
550
|
+
);
|
|
551
|
+
});
|