@nanobpm/nano-workforce 0.205.2 → 0.206.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/.github/workflows/release.yml +18 -3
- package/.releaserc.json +7 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/pollReviewsTerminal.test.ts +175 -0
- package/app/service.ts +62 -0
- package/app/version.test.ts +204 -0
- package/app/version.ts +64 -4
- package/build-info.json +6 -0
- package/docs/agent-guide.md +5 -0
- package/openapi.yaml +38 -0
- package/operations/getVersion.test.ts +3 -0
- package/operations/listActivePrs.test.ts +15 -0
- package/operations/listActivePrs.ts +5 -2
- package/package.json +2 -1
- package/scripts/write-build-info.test.ts +87 -0
- package/scripts/write-build-info.ts +126 -0
- package/workers/merge/worker.test.ts +99 -46
- package/workers/merge/worker.ts +27 -20
|
@@ -112,6 +112,13 @@ test("pr.merge short-circuits an already-merged PR without re-running the land p
|
|
|
112
112
|
// would stay green if the verb regressed.
|
|
113
113
|
type GithubCall = { url: string; method: string };
|
|
114
114
|
|
|
115
|
+
// A handler whose review backstop sees a VERIFIED head-current review, so land-protocol tests
|
|
116
|
+
// exercise the protocol dispatch (the backstop now fails CLOSED on an unreadable read, #927).
|
|
117
|
+
const reviewedHandler = makeHandler({
|
|
118
|
+
readReview: async () => ({ body: "lgtm", commitId: "reviewedhead" }),
|
|
119
|
+
readHeadSha: async () => "reviewedhead",
|
|
120
|
+
});
|
|
121
|
+
|
|
115
122
|
function withGithub(
|
|
116
123
|
routes: (url: string, init: RequestInit | undefined) => Response | null,
|
|
117
124
|
run: (calls: GithubCall[]) => Promise<void>,
|
|
@@ -153,7 +160,7 @@ test("pr.merge routes a mergify-queue repo through the queued branch (enqueue co
|
|
|
153
160
|
},
|
|
154
161
|
async (calls) => {
|
|
155
162
|
const { app, stores } = fakeApp();
|
|
156
|
-
const out = (await
|
|
163
|
+
const out = (await reviewedHandler(
|
|
157
164
|
{ variables: { prKey: "acme/widgets#7", repo: "acme/widgets", prNumber: 7 } } as any,
|
|
158
165
|
app,
|
|
159
166
|
)) as Record<string, unknown>;
|
|
@@ -189,7 +196,7 @@ test("pr.merge routes a refused merge through the blocked branch (escalation pay
|
|
|
189
196
|
},
|
|
190
197
|
async (calls) => {
|
|
191
198
|
const { app, stores } = fakeApp();
|
|
192
|
-
const out = (await
|
|
199
|
+
const out = (await reviewedHandler(
|
|
193
200
|
{ variables: { prKey: "acme/widgets#9", repo: "acme/widgets", prNumber: 9 } } as any,
|
|
194
201
|
app,
|
|
195
202
|
)) as Record<string, unknown>;
|
|
@@ -229,7 +236,7 @@ test("pr.merge routes a transient base-moved race through the retry branch (no e
|
|
|
229
236
|
},
|
|
230
237
|
async (calls) => {
|
|
231
238
|
const { app, stores } = fakeApp();
|
|
232
|
-
const out = (await
|
|
239
|
+
const out = (await reviewedHandler(
|
|
233
240
|
{ variables: { prKey: "acme/widgets#11", repo: "acme/widgets", prNumber: 11 } } as any,
|
|
234
241
|
app,
|
|
235
242
|
)) as Record<string, unknown>;
|
|
@@ -383,33 +390,63 @@ test("pr.merge #923: a HEAD-CURRENT review lands normally (backstop control)", a
|
|
|
383
390
|
);
|
|
384
391
|
});
|
|
385
392
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
393
|
+
for (const [label, readReview, readHeadSha] of [
|
|
394
|
+
["review read null", async () => null, async () => "h9"],
|
|
395
|
+
["review read throws", async () => { throw new Error("boom"); }, async () => "h9"],
|
|
396
|
+
["head read null", async () => ({ body: "lgtm", commitId: "h9" }), async () => null],
|
|
397
|
+
["head read throws", async () => ({ body: "lgtm", commitId: "h9" }), async () => { throw new Error("boom"); }],
|
|
398
|
+
["head read empty", async () => ({ body: "lgtm", commitId: "h9" }), async () => " "],
|
|
399
|
+
] as const) {
|
|
400
|
+
test(`pr.merge #927: an UNVERIFIABLE backstop read (${label}) fails CLOSED — no land, audited refusal`, async () => {
|
|
401
|
+
await withGithub(
|
|
402
|
+
(url) => {
|
|
403
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
404
|
+
return backstopRoutes(url);
|
|
405
|
+
},
|
|
406
|
+
async (calls) => {
|
|
407
|
+
const { app, stores } = fakeApp();
|
|
408
|
+
const backstop = makeHandler({ readReview, readHeadSha } as any);
|
|
409
|
+
const out = (await backstop(
|
|
410
|
+
{ variables: { prKey: "acme/widgets#27", repo: "acme/widgets", prNumber: 27 } } as any,
|
|
411
|
+
app,
|
|
412
|
+
)) as Record<string, unknown>;
|
|
413
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
414
|
+
assertEquals(out.status, "blocked");
|
|
415
|
+
assertEquals((out.question as string).includes("could not be read"), true);
|
|
416
|
+
assertEquals(stores.merges.length, 1);
|
|
417
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
418
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
419
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
420
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
404
425
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
426
|
+
test("pr.merge #927: reproduces nano-bpm#1467 — review REQUESTED but not yet arrived on a mergify-queue repo → no `@mergifyio queue`", async () => {
|
|
427
|
+
const protocol =
|
|
428
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
429
|
+
for (const readReview of [async () => ({ body: "", commitId: null }), async () => null]) {
|
|
430
|
+
await withGithub(
|
|
431
|
+
(url) => {
|
|
432
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
433
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
434
|
+
return backstopRoutes(url);
|
|
435
|
+
},
|
|
436
|
+
async (calls) => {
|
|
437
|
+
const { app, stores } = fakeApp();
|
|
438
|
+
const backstop = makeHandler({ readReview, readHeadSha: async () => "e597b399" } as any);
|
|
439
|
+
const out = (await backstop(
|
|
440
|
+
{ variables: { prKey: "acme/widgets#1467", repo: "acme/widgets", prNumber: 1467 } } as any,
|
|
441
|
+
app,
|
|
442
|
+
)) as Record<string, unknown>;
|
|
443
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
444
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
445
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
446
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
}
|
|
413
450
|
});
|
|
414
451
|
|
|
415
452
|
test("pr.merge #923: a head-current review whose summary reports OPEN findings blocks landing", async () => {
|
|
@@ -427,23 +464,6 @@ test("pr.merge #923: a head-current review whose summary reports OPEN findings b
|
|
|
427
464
|
});
|
|
428
465
|
});
|
|
429
466
|
|
|
430
|
-
for (const [label, readHeadSha] of [
|
|
431
|
-
["returns null", async () => null],
|
|
432
|
-
["throws", async () => { throw new Error("transient"); }],
|
|
433
|
-
] as const) {
|
|
434
|
-
test(`pr.merge #923: an unverifiable head (read ${label}) never blocks on a possibly-stale review's open findings`, async () => {
|
|
435
|
-
await withGithub(backstopRoutes, async () => {
|
|
436
|
-
const { app, stores } = fakeApp();
|
|
437
|
-
const backstop = makeHandler({
|
|
438
|
-
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "old" }),
|
|
439
|
-
readHeadSha,
|
|
440
|
-
});
|
|
441
|
-
await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app);
|
|
442
|
-
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
443
|
-
});
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
467
|
test("pr.merge #923: the direct merge pins the verified head SHA (no check-then-merge race)", async () => {
|
|
448
468
|
let putBody = "";
|
|
449
469
|
await withGithub(
|
|
@@ -485,3 +505,36 @@ test("pr.merge #923: a queue-comment repo whose head moved after the backstop re
|
|
|
485
505
|
},
|
|
486
506
|
);
|
|
487
507
|
});
|
|
508
|
+
|
|
509
|
+
for (const [label, finalRead] of [
|
|
510
|
+
["null", async () => null],
|
|
511
|
+
["throwing", async () => {
|
|
512
|
+
throw new Error("502");
|
|
513
|
+
}],
|
|
514
|
+
["blank", async () => " "],
|
|
515
|
+
] as const) {
|
|
516
|
+
test(`pr.merge #927: a queue-comment repo whose FINAL head re-read is ${label} retries instead of enqueueing`, async () => {
|
|
517
|
+
const protocol =
|
|
518
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
519
|
+
await withGithub(
|
|
520
|
+
(url) => {
|
|
521
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
522
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
523
|
+
return backstopRoutes(url);
|
|
524
|
+
},
|
|
525
|
+
async (calls) => {
|
|
526
|
+
const { app, stores } = fakeApp();
|
|
527
|
+
let n = 0;
|
|
528
|
+
const backstop = makeHandler({
|
|
529
|
+
readReview: async () => ({ body: "lgtm", commitId: "h3" }),
|
|
530
|
+
readHeadSha: async () => (n++ === 0 ? "h3" : finalRead()),
|
|
531
|
+
} as any);
|
|
532
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#34", repo: "acme/widgets", prNumber: 34 } } as any, app)) as Record<string, unknown>;
|
|
533
|
+
assertEquals(out.mergeStatus, "retry");
|
|
534
|
+
assertEquals(stores.merges.some((m) => m.outcome === "retry"), true, "retry audit row");
|
|
535
|
+
assertEquals(calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"), false);
|
|
536
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false, "no merge call");
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
}
|
package/workers/merge/worker.ts
CHANGED
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
// it reviewed — a head that MOVED between convergence and landing, or a review that never existed
|
|
16
16
|
// (the #1443 fail-open), must not slip through at land time. Before running ANY land protocol this
|
|
17
17
|
// worker re-checks that a HEAD-CURRENT Copilot review exists; a missing/stale review blocks the
|
|
18
|
-
// landing (escalating to a human) instead of enqueueing/merging unreviewed code.
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// would wedge every merge on a network blip.
|
|
18
|
+
// landing (escalating to a human) instead of enqueueing/merging unreviewed code. An UNREADABLE
|
|
19
|
+
// review/head ALSO blocks (fails CLOSED, issue #927) — the old fail-open let nano-bpm#1467 land
|
|
20
|
+
// 49 s before its first review.
|
|
22
21
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
23
22
|
import { matchTags, tag } from "@nanobpm/urban/effect";
|
|
24
23
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
@@ -59,6 +58,9 @@ const defaultReadReview: MergeReviewReader = (repo, prNumber) =>
|
|
|
59
58
|
// bind (#786/#799), so the backstop's staleness comparison sees the exact head the gate did.
|
|
60
59
|
const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
|
|
61
60
|
|
|
61
|
+
const BLOCK_UNVERIFIABLE_REVIEW =
|
|
62
|
+
"the PR's Copilot review state or current HEAD could not be read, so a head-current reviewer verdict cannot be verified (the backstop fails CLOSED on an unverifiable read)";
|
|
63
|
+
|
|
62
64
|
const BLOCK_NO_HEAD_CURRENT_REVIEW =
|
|
63
65
|
"no head-current Copilot review exists for the PR's current HEAD (the head moved after the last review, or no review was ever submitted). A head-current reviewer verdict is required before landing";
|
|
64
66
|
|
|
@@ -132,26 +134,31 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
132
134
|
// existed at all (the nano-bpm#1443 fail-open: merged with ZERO reviews), must not slip through
|
|
133
135
|
// at land time. Runs AFTER the merged/closed short-circuits (an already-terminal PR needs no
|
|
134
136
|
// verdict) and BEFORE any land protocol (the `@mergifyio queue` comment is itself the landing
|
|
135
|
-
// for a queue repo). Fails
|
|
136
|
-
// hiccup → `null` review or head): a transient read failure must not wedge every merge — the
|
|
137
|
-
// merge-stage mergeability polling is the independent backstop there.
|
|
137
|
+
// for a queue repo). Fails CLOSED on an UNREADABLE review/head too (issue #927).
|
|
138
138
|
const [backstopReview, backstopHead] = await Promise.all([
|
|
139
139
|
readReview(repo, prNumber).catch(() => null),
|
|
140
140
|
readHeadSha(repo, prNumber).catch(() => null),
|
|
141
141
|
]);
|
|
142
|
-
|
|
142
|
+
// FAIL CLOSED (issue #927): a gate whose job is "never land unreviewed" must not land on an
|
|
143
|
+
// UNVERIFIABLE read. nano-bpm#1467 merged 49 s before its first Copilot review because a `null`
|
|
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) exactly like a missing/stale review.
|
|
146
|
+
const headTrimmed = (backstopHead ?? "").trim();
|
|
147
|
+
const unverifiable = backstopReview === null || headTrimmed === "";
|
|
148
|
+
if (unverifiable || isReviewMissingOrStale(backstopReview.commitId, headTrimmed)) {
|
|
149
|
+
const detail = unverifiable ? BLOCK_UNVERIFIABLE_REVIEW : BLOCK_NO_HEAD_CURRENT_REVIEW;
|
|
143
150
|
await app.data.table("merges", "id").insert({
|
|
144
151
|
pr_key: prKey,
|
|
145
152
|
outcome: "blocked",
|
|
146
153
|
method: "review-backstop",
|
|
147
|
-
detail
|
|
154
|
+
detail,
|
|
148
155
|
at: now,
|
|
149
156
|
});
|
|
150
157
|
return {
|
|
151
158
|
mergeStatus: "blocked",
|
|
152
159
|
status: "blocked",
|
|
153
160
|
question:
|
|
154
|
-
`Merge refused: ${
|
|
161
|
+
`Merge refused: ${detail}. ` +
|
|
155
162
|
`Push no further commits, wait for the Copilot review of the current HEAD to land (or request one: ` +
|
|
156
163
|
`gh api repos/${repo}/pulls/${prNumber}/requested_reviewers -X POST -f "reviewers[]=copilot-pull-request-reviewer[bot]"), ` +
|
|
157
164
|
`let the convergence loop gate on it, then reply to retry.`,
|
|
@@ -161,12 +168,8 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
161
168
|
// A head-current review whose OWN summary still reports open findings (the same canonical
|
|
162
169
|
// verdict parser the converge-gate uses) must block too: a summary-only "Changes recommended"
|
|
163
170
|
// review that landed after convergence has no unresolved thread for mergeability polling to see.
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
// possibly-stale review's body.
|
|
167
|
-
const reviewIsHeadCurrent =
|
|
168
|
-
backstopReview !== null && backstopHead !== null && backstopHead !== "" && backstopReview.commitId === backstopHead;
|
|
169
|
-
const openFindings = reviewIsHeadCurrent ? parseReviewOpenFindings(backstopReview.body) : [];
|
|
171
|
+
// Head-currentness is VERIFIED at this point (the fail-closed check above returned otherwise).
|
|
172
|
+
const openFindings = parseReviewOpenFindings(backstopReview.body);
|
|
170
173
|
if (openFindings.length > 0) {
|
|
171
174
|
const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
|
|
172
175
|
await app.data.table("merges", "id").insert({
|
|
@@ -186,7 +189,7 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
186
189
|
}
|
|
187
190
|
// The head the backstop verified — pinned through the land call so a push between this check
|
|
188
191
|
// and the merge can never land a later, unreviewed head.
|
|
189
|
-
const verifiedHead
|
|
192
|
+
const verifiedHead: string | undefined = headTrimmed;
|
|
190
193
|
|
|
191
194
|
// Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
|
|
192
195
|
// to the default branch — the merge would land into a dead branch and never reach `main`.
|
|
@@ -227,10 +230,14 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
227
230
|
const comment = protocol?.land.comment ?? "@mergifyio queue";
|
|
228
231
|
// A comment can't pin a SHA, so re-read the head right before enqueueing: if it moved past the
|
|
229
232
|
// verified head, re-enter via the bounded retry (which re-runs the backstop on the new head).
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
// Fail CLOSED (#927): an unreadable re-read retries rather than enqueueing an unverified head.
|
|
234
|
+
const headNow = await readHeadSha(repo, prNumber).catch(() => null);
|
|
235
|
+
if (headNow === null || headNow.trim() !== verifiedHead) {
|
|
232
236
|
outcome = "retry";
|
|
233
|
-
detail =
|
|
237
|
+
detail =
|
|
238
|
+
headNow === null
|
|
239
|
+
? `head could not be re-read after the review backstop; re-verifying before enqueue`
|
|
240
|
+
: `head moved from ${verifiedHead} to ${headNow} after the review backstop; re-verifying before enqueue`;
|
|
234
241
|
} else {
|
|
235
242
|
const ok = await enqueueViaComment(repo, prNumber, token, comment);
|
|
236
243
|
outcome = ok ? "queued" : "blocked";
|