@nanobpm/nano-workforce 0.205.1 → 0.206.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/.github/workflows/release.yml +18 -3
- package/.releaserc.json +7 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.ts +53 -3
- package/app/mergeLoopBehaviour.test.ts +12 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/reviewWait.test.ts +37 -0
- package/app/reviewWait.ts +26 -0
- package/app/roundProgress.test.ts +63 -2
- package/app/roundProgress.ts +6 -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/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +1 -1
- package/scripts/write-build-info.test.ts +87 -0
- package/scripts/write-build-info.ts +126 -0
- package/workers/converge-gate/worker.ts +61 -11
- package/workers/merge/worker.test.ts +192 -1
- package/workers/merge/worker.ts +118 -7
- package/workers/progress-check/worker.ts +40 -6
|
@@ -197,8 +197,11 @@ jobs:
|
|
|
197
197
|
# token, still OIDC + provenance via the job's id-token. This step is a NO-OP
|
|
198
198
|
# when the release step succeeded or when the version is already on npm, so it
|
|
199
199
|
# can never double-publish. If OIDC stays down through every retry, fail loudly:
|
|
200
|
-
# a human must `npm publish` the pushed tag locally
|
|
201
|
-
#
|
|
200
|
+
# a human must `npm publish` the pushed tag locally — restoring the build identity first,
|
|
201
|
+
# because a fresh tag checkout has no build-info.json and plain `npm publish` skips the
|
|
202
|
+
# prepare hook (npm login with 2FA, then `git checkout vX.Y.Z && npm ci &&
|
|
203
|
+
# node --experimental-strip-types scripts/write-build-info.ts X.Y.Z --sha "$(git rev-parse
|
|
204
|
+
# vX.Y.Z^)" && npm publish --provenance --access public`; see the ::error:: message below).
|
|
202
205
|
- name: Retry OIDC publish if the release publish failed
|
|
203
206
|
if: steps.release.outcome == 'failure'
|
|
204
207
|
env:
|
|
@@ -213,6 +216,18 @@ jobs:
|
|
|
213
216
|
exit 0
|
|
214
217
|
fi
|
|
215
218
|
echo "semantic-release prepared + tagged ${PKG}@${VERSION} but the OIDC publish failed; retrying."
|
|
219
|
+
# Reuse the build-info.json that semantic-release's prepare step already wrote (prepareCmd
|
|
220
|
+
# in .releaserc.json) — do NOT regenerate it here. By the time this retry runs,
|
|
221
|
+
# @semantic-release/git has already created the release commit, so HEAD is that
|
|
222
|
+
# metadata-only `chore(release)` commit, NOT the source HEAD the release was cut from.
|
|
223
|
+
# Re-running write-build-info.ts would call `git rev-parse HEAD` and overwrite the recorded
|
|
224
|
+
# source SHA with the release commit's SHA (and replace builtAt), making the documented
|
|
225
|
+
# identity depend on whether OIDC publishing failed. The prepared file is already present
|
|
226
|
+
# in this checkout with the correct source HEAD; this step re-packs via plain `npm publish`
|
|
227
|
+
# (which runs OUTSIDE semantic-release, so the prepare hook does not re-run), so the baked
|
|
228
|
+
# file is packed as-is. Validate it is present and well-formed (fail loudly if a future
|
|
229
|
+
# change ever drops it) rather than regenerate it from the changed HEAD (issue #925).
|
|
230
|
+
node --experimental-strip-types scripts/write-build-info.ts --check
|
|
216
231
|
published=false
|
|
217
232
|
for attempt in 1 2 3; do
|
|
218
233
|
echo "OIDC publish attempt ${attempt}/3 for ${PKG}@${VERSION}"
|
|
@@ -224,7 +239,7 @@ jobs:
|
|
|
224
239
|
sleep $((attempt * 30))
|
|
225
240
|
done
|
|
226
241
|
if [ "$published" != true ]; then
|
|
227
|
-
echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally: 'git fetch --tags && git checkout v${VERSION} && npm ci && npm publish --provenance --access public' (npm
|
|
242
|
+
echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally (npm login with 2FA first): 'git fetch --tags && git checkout v${VERSION} && npm ci && node --experimental-strip-types scripts/write-build-info.ts ${VERSION} --sha \"\$(git rev-parse v${VERSION}^)\" && node --experimental-strip-types scripts/write-build-info.ts --check && npm publish --provenance --access public'. The write-build-info step is REQUIRED: a fresh tag checkout has no build-info.json (it is an uncommitted build artifact) and plain npm publish does not run the semantic-release prepare hook, so without it the package ships with gitSha: null. Passing --sha \"\$(git rev-parse v${VERSION}^)\" records the SOURCE HEAD (the tag's parent), not the metadata-only release commit the tag points at."
|
|
228
243
|
exit 1
|
|
229
244
|
fi
|
|
230
245
|
# semantic-release aborts before @semantic-release/github when the npm
|
package/.releaserc.json
CHANGED
|
@@ -40,6 +40,13 @@
|
|
|
40
40
|
"@semantic-release/changelog",
|
|
41
41
|
"@semantic-release/npm",
|
|
42
42
|
"@semantic-release/github",
|
|
43
|
+
[
|
|
44
|
+
"@semantic-release/exec",
|
|
45
|
+
{
|
|
46
|
+
"//": "Bake the build identity into the package (issue #925). prepareCmd runs in semantic-release's prepare step — AFTER @semantic-release/npm bumps package.json to ${nextRelease.version} and BEFORE it packs/publishes the tarball — so the published package ships build-info.json and an npm-installed instance (no .git, no env override) can still report its gitSha. The recorded gitSha is the SOURCE HEAD the release is cut from; the tag commit is created afterwards by @semantic-release/git (see scripts/write-build-info.ts). There is deliberately NO verifyConditionsCmd: semantic-release runs verifyConditions BEFORE it computes nextRelease, so ${nextRelease.version} interpolates empty there and the command would fail with EVERIFYCONDITIONS, blocking every release. The workflow's OIDC-retry step re-runs plain `npm publish` (which re-packs) in the same checkout — where prepare already wrote build-info.json — and validates + reuses that prepared file (it does NOT regenerate it: by then HEAD is the release commit, so re-running the writer would overwrite the source SHA with the release commit's SHA; see release.yml), so the retry never publishes a tarball with no or wrong build-info.json.",
|
|
47
|
+
"prepareCmd": "node --experimental-strip-types scripts/write-build-info.ts ${nextRelease.version}"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
43
50
|
[
|
|
44
51
|
"@semantic-release/git",
|
|
45
52
|
{
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.206.0](https://github.com/nanobpm/nano-workforce/compare/v0.205.2...v0.206.0) (2026-11-08)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **version:** bake the git SHA into release packages and report identity on /status ([#926](https://github.com/nanobpm/nano-workforce/issues/926)) ([e5eedca](https://github.com/nanobpm/nano-workforce/commit/e5eedca0ee28db1f54167b4bb962ce4c06776e9f)), closes [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925)
|
|
6
|
+
|
|
7
|
+
## [0.205.2](https://github.com/nanobpm/nano-workforce/compare/v0.205.1...v0.205.2) (2026-11-08)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **convergence:** fail closed when no head-current Copilot review exists ([#924](https://github.com/nanobpm/nano-workforce/issues/924)) ([e0ded57](https://github.com/nanobpm/nano-workforce/commit/e0ded57e0888755fa6fd77495f3361e8dafc0980)), closes [#923](https://github.com/nanobpm/nano-workforce/issues/923) [nano-bpm#1443](https://github.com/nanobpm/nano-bpm/issues/1443) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923)
|
|
12
|
+
|
|
1
13
|
## [0.205.1](https://github.com/nanobpm/nano-workforce/compare/v0.205.0...v0.205.1) (2026-11-06)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -301,7 +301,7 @@ export const ENV_CONTRACTS = {
|
|
|
301
301
|
name: "NANO_WORKFORCE_GIT_SHA",
|
|
302
302
|
owner: "app/version.ts",
|
|
303
303
|
semantics:
|
|
304
|
-
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset.",
|
|
304
|
+
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset, then falls back to the release-baked build-info.json (issue #925).",
|
|
305
305
|
},
|
|
306
306
|
NANO_AUTO_RETRO: {
|
|
307
307
|
category: "env",
|
|
@@ -292,3 +292,57 @@ test("the pass budget resets when the next review lands (new round)", async () =
|
|
|
292
292
|
// Round 2 gets its own full budget of 1 pass.
|
|
293
293
|
assert(completions(engine, "adversarial-review") === 2, `round 2 must get a fresh pass: ${runs(engine)}`);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
// Issue #923 §4 (behavioural, not substring): a `converged` agent verdict with NO head-current review
|
|
297
|
+
// makes the converge-gate report `reviewStale`; the loop must record the round as `review-stale`
|
|
298
|
+
// (not the agent's self-reported `converged`) and park for a fresh review — never reaching
|
|
299
|
+
// `persist-converged`.
|
|
300
|
+
test("#923: a stale converge-gate verdict persists the round as review-stale and parks without converging", async () => {
|
|
301
|
+
const { engine, seen } = await boot({
|
|
302
|
+
responses: {
|
|
303
|
+
"senior:pr-review": [{ status: "converged", summary: "done" }, { status: "waiting", summary: "awaiting" }],
|
|
304
|
+
"pr.converge-gate": {
|
|
305
|
+
convergeBlocked: false,
|
|
306
|
+
convergeBlockReason: "",
|
|
307
|
+
convergeAckOnly: false,
|
|
308
|
+
reviewStale: true,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
vars: { advMax: 0 },
|
|
312
|
+
});
|
|
313
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
314
|
+
assert(completions(engine, "persist-converged") === 0, `must not converge on a stale review: ${runs(engine)}`);
|
|
315
|
+
const recorded = seen["pr.persist-round"][0];
|
|
316
|
+
assert(recorded?.status === "review-stale", `persist-round status: ${JSON.stringify(recorded?.status)}`);
|
|
317
|
+
assert(completions(engine, "wait-review") === 0, "must be parked waiting for a fresh review");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Issue #923 review ("previously missed", behavioural): the arrival-window cutoff the converge-gate
|
|
321
|
+
// emits on a stale verdict must PROPAGATE through the process to the check-progress park — the
|
|
322
|
+
// variable crosses persist-round untouched, so check-progress can stamp `waiting_since` at the
|
|
323
|
+
// gate's read time (not the later park time) and a head-current review landing in the read→park
|
|
324
|
+
// window still satisfies the wait. This is the process-level wiring the worker unit tests cannot
|
|
325
|
+
// see (each worker is tested in isolation with an injected variable).
|
|
326
|
+
test("#923 review: the converge-gate's reviewWaitCutoff reaches check-progress on a stale verdict", async () => {
|
|
327
|
+
const CUTOFF = "2026-11-08T01:00:00.000Z";
|
|
328
|
+
const { engine, seen } = await boot({
|
|
329
|
+
responses: {
|
|
330
|
+
"senior:pr-review": [{ status: "converged", summary: "done" }, { status: "waiting", summary: "awaiting" }],
|
|
331
|
+
"pr.converge-gate": {
|
|
332
|
+
convergeBlocked: false,
|
|
333
|
+
convergeBlockReason: "",
|
|
334
|
+
convergeAckOnly: false,
|
|
335
|
+
reviewStale: true,
|
|
336
|
+
reviewWaitCutoff: CUTOFF,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
vars: { advMax: 0 },
|
|
340
|
+
});
|
|
341
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
342
|
+
const progressJobs = seen["pr.progress-check"];
|
|
343
|
+
assert(progressJobs.length >= 1, "check-progress ran");
|
|
344
|
+
assert(
|
|
345
|
+
progressJobs[0]?.reviewWaitCutoff === CUTOFF,
|
|
346
|
+
`check-progress must receive the gate's reviewWaitCutoff, got ${JSON.stringify(progressJobs[0]?.reviewWaitCutoff)}`,
|
|
347
|
+
);
|
|
348
|
+
});
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
advisoryStableKey,
|
|
18
18
|
isAckThread,
|
|
19
19
|
parseAckedAdvisories,
|
|
20
|
+
parseReviewOpenFindings,
|
|
20
21
|
parseReviewThreadsPage,
|
|
21
22
|
parseSuppressedAdvisories,
|
|
22
23
|
pickLatestCopilotReview,
|
|
@@ -820,7 +821,11 @@ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not bloc
|
|
|
820
821
|
headSha: "newsha2222222222222222222222222222222222",
|
|
821
822
|
});
|
|
822
823
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
823
|
-
assertEquals(out
|
|
824
|
+
assertEquals(out.convergeBlocked, false);
|
|
825
|
+
assertEquals(out.reviewStale, true);
|
|
826
|
+
// The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
|
|
827
|
+
// landing in the read→park window still satisfies the wait (issue #923 review).
|
|
828
|
+
assertEquals(typeof out.reviewWaitCutoff, "string");
|
|
824
829
|
});
|
|
825
830
|
|
|
826
831
|
test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
|
|
@@ -838,9 +843,14 @@ test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advis
|
|
|
838
843
|
assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
|
|
839
844
|
});
|
|
840
845
|
|
|
841
|
-
test("converge-gate #799:
|
|
842
|
-
//
|
|
843
|
-
//
|
|
846
|
+
test("converge-gate #799/#923: a review commit_id GitHub did not carry is treated as MISSING — re-solicits (no fail-open)", async () => {
|
|
847
|
+
// #799's fail-safe ("a null review commit_id is not stale") was written for a gate that only
|
|
848
|
+
// handled a review that EXISTS. #923 closes the class: a null commit_id is ambiguous between "no
|
|
849
|
+
// review at all" and "a review GitHub carried no commit_id for", and the gate cannot distinguish
|
|
850
|
+
// them — converging on either is converging without a verifiable head-current verdict (the
|
|
851
|
+
// #1443 fail-open). With a readable head the gate now signals reviewStale for BOTH, so the
|
|
852
|
+
// poller re-solicits a review whose commit_id IS carried, un-wedging the loop. The head-side
|
|
853
|
+
// fail-safe is unchanged: an unreadable head still evaluates normally (see the #923 test below).
|
|
844
854
|
const handler = await makeUnderTest({
|
|
845
855
|
readThreads: async () => [],
|
|
846
856
|
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
@@ -848,8 +858,66 @@ test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT
|
|
|
848
858
|
headSha: "newsha2222222222222222222222222222222222",
|
|
849
859
|
});
|
|
850
860
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
851
|
-
assertEquals(out.convergeBlocked,
|
|
852
|
-
assertEquals(out.reviewStale,
|
|
861
|
+
assertEquals(out.convergeBlocked, false);
|
|
862
|
+
assertEquals(out.reviewStale, true);
|
|
863
|
+
// The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
|
|
864
|
+
// landing in the read→park window still satisfies the wait (issue #923 review).
|
|
865
|
+
assertEquals(typeof out.reviewWaitCutoff, "string");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// ── Fail closed on "no review exists" (issue #923 §1) ────────────────────────
|
|
869
|
+
// The gate used to treat `{ body: "", commitId: null }` — a VERIFIED "no Copilot review on this
|
|
870
|
+
// PR" — as "a verified clean review": zero threads + zero advisories → not blocked → merged with
|
|
871
|
+
// ZERO reviews (nano-bpm#1443). Convergence requires a HEAD-CURRENT reviewer verdict, so a missing
|
|
872
|
+
// review must signal `reviewStale` and route to the review wait (the poller solicits a review of
|
|
873
|
+
// the head), exactly like a stale one.
|
|
874
|
+
|
|
875
|
+
test("converge-gate #923: NO Copilot review at all signals reviewStale (never converges unreviewed)", async () => {
|
|
876
|
+
// `readReview` returns `{ body: "", commitId: null }` (verified: no Copilot review) and the head
|
|
877
|
+
// read succeeds. The old gate fell through as the most permissive case and converged.
|
|
878
|
+
const handler = await makeUnderTest({
|
|
879
|
+
readThreads: async () => [],
|
|
880
|
+
readReviewBody: async () => "",
|
|
881
|
+
reviewCommitId: null,
|
|
882
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
883
|
+
});
|
|
884
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
885
|
+
assertEquals(out.convergeBlocked, false);
|
|
886
|
+
assertEquals(out.reviewStale, true);
|
|
887
|
+
// The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
|
|
888
|
+
// landing in the read→park window still satisfies the wait (issue #923 review).
|
|
889
|
+
assertEquals(typeof out.reviewWaitCutoff, "string");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("converge-gate #923: a review GitHub carried no commit_id for is ALSO treated as missing (fail closed)", async () => {
|
|
893
|
+
// A null commit_id is ambiguous — "no review" vs "a review with no commit_id" — and the gate
|
|
894
|
+
// cannot distinguish them, so BOTH must take the re-solicit path. Converging on an unverifiable
|
|
895
|
+
// verdict is the fail-open this issue closes; the re-solicit produces a review whose commit_id
|
|
896
|
+
// IS carried, un-wedging the loop.
|
|
897
|
+
const handler = await makeUnderTest({
|
|
898
|
+
readThreads: async () => [],
|
|
899
|
+
readReviewBody: async () => "## Overview\nNo suppressed block.",
|
|
900
|
+
reviewCommitId: null,
|
|
901
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
902
|
+
});
|
|
903
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
904
|
+
assertEquals(out.reviewStale, true);
|
|
905
|
+
assertEquals(out.convergeBlocked, false);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("converge-gate #923: a missing review with an UNREADABLE head still fails safe (no fabricated re-solicit)", async () => {
|
|
909
|
+
// Without the head we cannot prove anything — the gate keeps `isReviewStale`'s fail-safe and
|
|
910
|
+
// falls through to the ordinary evaluation (here: a clean empty review converges). A transport
|
|
911
|
+
// hiccup must never strand the loop re-soliciting forever; the review-wait timeout is the
|
|
912
|
+
// backstop.
|
|
913
|
+
const handler = await makeUnderTest({
|
|
914
|
+
readThreads: async () => [],
|
|
915
|
+
readReviewBody: async () => "",
|
|
916
|
+
reviewCommitId: null,
|
|
917
|
+
headSha: null,
|
|
918
|
+
});
|
|
919
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
920
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
853
921
|
});
|
|
854
922
|
|
|
855
923
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
@@ -971,6 +1039,20 @@ test("the round cap does NOT escalate a stale-retry round — f_guardMax is gate
|
|
|
971
1039
|
assertStringIncludes(f, "maxRounds and reviewStale != true");
|
|
972
1040
|
});
|
|
973
1041
|
|
|
1042
|
+
test("persist-round records a stale re-entry as 'review-stale', not the agent's self-report (#923 §4)", () => {
|
|
1043
|
+
// Round accounting must reflect whether a reviewer verdict was obtained: a round that re-enters
|
|
1044
|
+
// persist-round via f_convergeStale (no head-current review existed, or it predated the head)
|
|
1045
|
+
// never received a verdict, so recording the agent's self-reported status ("converged") would
|
|
1046
|
+
// tell the operator a review round happened that never did (/status showed nano-bpm#1443 as
|
|
1047
|
+
// "round 2" with zero reviews). The stale re-entry maps `status` from `reviewStale` so the
|
|
1048
|
+
// durable round history reads `review-stale`.
|
|
1049
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
1050
|
+
assert(task, "persist-round task missing");
|
|
1051
|
+
assertStringIncludes(task[0], 'target="status"');
|
|
1052
|
+
assertStringIncludes(task[0], "reviewStale");
|
|
1053
|
+
assertStringIncludes(task[0], "review-stale");
|
|
1054
|
+
});
|
|
1055
|
+
|
|
974
1056
|
test("wait-review clears reviewStale when a fresh review lands, so the marker can't leak into a later round (#799)", () => {
|
|
975
1057
|
// reviewStale is written ONLY by the converge-gate; once a fresh review arrives it is no longer
|
|
976
1058
|
// known-stale, so wait-review resets it to false alongside the round increment. Without this reset
|
|
@@ -1196,3 +1278,101 @@ test("parseSuppressedAdvisories: v2 table rows without findings yield nothing; n
|
|
|
1196
1278
|
);
|
|
1197
1279
|
assertEquals(parseSuppressedAdvisories("| `a.ts` | Broken thing (moderate, 1 vote). |"), []);
|
|
1198
1280
|
});
|
|
1281
|
+
|
|
1282
|
+
// ── The reviewer-verdict gate (issue #923 §2) ────────────────────────────────
|
|
1283
|
+
// Convergence requires a head-current reviewer VERDICT, not just zero unresolved threads: a
|
|
1284
|
+
// head-current review whose own summary reports open findings must block the same way an
|
|
1285
|
+
// unresolved thread does. `parseReviewOpenFindings` reads that verdict out of the review body
|
|
1286
|
+
// (Copilot's "Changes recommended" / "N open issue(s)" / ccr-overview-v2 "Open (N)" section).
|
|
1287
|
+
|
|
1288
|
+
test("parseReviewOpenFindings: a 'Changes recommended' review reports open findings", () => {
|
|
1289
|
+
const body = "## Pull Request Overview\n🟡 Changes recommended — 7 findings (6 high).";
|
|
1290
|
+
assertEquals(parseReviewOpenFindings(body), ["changes recommended"]);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("parseReviewOpenFindings: an 'N open issue(s)' summary reports N", () => {
|
|
1294
|
+
assertEquals(
|
|
1295
|
+
parseReviewOpenFindings("<details open>\n<summary><strong>3 open issues</strong></summary>\n</details>"),
|
|
1296
|
+
["3 open issues"],
|
|
1297
|
+
);
|
|
1298
|
+
assertEquals(parseReviewOpenFindings("<summary>1 open issue</summary>"), ["1 open issue"]);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("parseReviewOpenFindings: '0 open issues' is a clean verdict, not a finding", () => {
|
|
1302
|
+
assertEquals(parseReviewOpenFindings("Review complete. 0 open issues."), []);
|
|
1303
|
+
assertEquals(parseReviewOpenFindings("<details><summary><strong>0 open issues</strong></summary></details>"), []);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("parseReviewOpenFindings: a ccr-overview-v2 'Open (N)' section reports N", () => {
|
|
1307
|
+
assertEquals(parseReviewOpenFindings(V2_REVIEW_BODY), ["1 open finding"]);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
test("parseReviewOpenFindings: a clean review (no open-issues signal) reports none", () => {
|
|
1311
|
+
assertEquals(parseReviewOpenFindings("## Pull Request Overview\n✅ Looks good — no issues found."), []);
|
|
1312
|
+
assertEquals(parseReviewOpenFindings(""), []);
|
|
1313
|
+
assertEquals(parseReviewOpenFindings(null), []);
|
|
1314
|
+
assertEquals(parseReviewOpenFindings(undefined), []);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test("parseReviewOpenFindings: verdict phrases in descriptive prose / table rows are NOT findings", () => {
|
|
1318
|
+
const clean = [
|
|
1319
|
+
"<!-- ccr-overview-v2 -->",
|
|
1320
|
+
"",
|
|
1321
|
+
"### ✅ Looks good",
|
|
1322
|
+
"",
|
|
1323
|
+
"<details>",
|
|
1324
|
+
"<summary><strong>What changed in this PR</strong></summary>",
|
|
1325
|
+
"",
|
|
1326
|
+
"| File | Summary and final review notes |",
|
|
1327
|
+
"|---|---|",
|
|
1328
|
+
'| `a.ts` | Recognizes "Changes recommended" and counts 3 open issues; parses Open (2) sections. |',
|
|
1329
|
+
"</details>",
|
|
1330
|
+
"<summary>Notes</summary> then later Open (4) outside any summary",
|
|
1331
|
+
].join("\n");
|
|
1332
|
+
assertEquals(parseReviewOpenFindings(clean), []);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
test("parseReviewOpenFindings: a v2 overview with NO Open section reports none", () => {
|
|
1336
|
+
const clean = [
|
|
1337
|
+
"<!-- ccr-overview-v2 -->",
|
|
1338
|
+
"",
|
|
1339
|
+
"## Copilot review overview",
|
|
1340
|
+
"",
|
|
1341
|
+
"<details>",
|
|
1342
|
+
"<summary><strong>What changed in this PR</strong></summary>",
|
|
1343
|
+
"",
|
|
1344
|
+
"| File | Summary and final review notes |",
|
|
1345
|
+
"|---|---|",
|
|
1346
|
+
"| `a.ts` | Adds a thing. |",
|
|
1347
|
+
"</details>",
|
|
1348
|
+
].join("\n");
|
|
1349
|
+
assertEquals(parseReviewOpenFindings(clean), []);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("converge-gate #923: a head-current review whose body reports OPEN ISSUES blocks (verdict gate, no threads)", async () => {
|
|
1353
|
+
// The verdict gate: zero unresolved threads and no suppressed advisories, but the head-current
|
|
1354
|
+
// review's own summary says findings are open. This must block EXACTLY like an unresolved
|
|
1355
|
+
// thread — NOT ack-only (the round agent's code/reply work is needed, not a nano-ack repost).
|
|
1356
|
+
const handler = await makeUnderTest({
|
|
1357
|
+
readThreads: async () => [],
|
|
1358
|
+
readReviewBody: async () => "## Pull Request Overview\n🟡 Changes recommended — 7 findings (6 high).",
|
|
1359
|
+
reviewCommitId: "headsha4444444444444444444444444444444444",
|
|
1360
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
1361
|
+
});
|
|
1362
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
1363
|
+
assertEquals(out.convergeBlocked, true);
|
|
1364
|
+
assertEquals(out.reviewStale, false);
|
|
1365
|
+
assertEquals(out.convergeAckOnly, false);
|
|
1366
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "changes recommended");
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("converge-gate #923: a head-current CLEAN review with no findings still converges (verdict-gate control)", async () => {
|
|
1370
|
+
const handler = await makeUnderTest({
|
|
1371
|
+
readThreads: async () => [],
|
|
1372
|
+
readReviewBody: async () => "## Pull Request Overview\n✅ Looks good — no issues found.",
|
|
1373
|
+
reviewCommitId: "headsha4444444444444444444444444444444444",
|
|
1374
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
1375
|
+
});
|
|
1376
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
1377
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
1378
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -290,6 +290,37 @@ export function parseSuppressedAdvisories(reviewBody: string | null | undefined)
|
|
|
290
290
|
return out;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/** Read the reviewer VERDICT out of a Copilot review body (issue #923 §2): does the review's own
|
|
294
|
+
* summary report OPEN, actionable findings? Convergence requires a head-current verdict that is
|
|
295
|
+
* CLEAN — a review whose body says findings are still open must block the same way an unresolved
|
|
296
|
+
* thread does, even when every inline thread happens to be resolved (a review can carry findings
|
|
297
|
+
* with no unresolved threads at all: the nano-bpm#1443 review raised 7 findings as a summary-only
|
|
298
|
+
* "Changes recommended").
|
|
299
|
+
*
|
|
300
|
+
* Recognised open-finding signals (each returns a short human label for the block reason):
|
|
301
|
+
* • "Changes recommended" — Copilot's 🟡 verdict line (vs. the ✅ clean verdict);
|
|
302
|
+
* • "N open issue(s)" — the summary count Copilot emits when findings remain;
|
|
303
|
+
* • a ccr-overview-v2 `<summary>…Open (N)…</summary>` section (issue #835's format) with N ≥ 1.
|
|
304
|
+
* A body carrying NONE of these reports no open findings (empty array) — including the empty body
|
|
305
|
+
* of a comment-only review. Pure; unit-tested. */
|
|
306
|
+
export function parseReviewOpenFindings(reviewBody: string | null | undefined): string[] {
|
|
307
|
+
const body = reviewBody ?? "";
|
|
308
|
+
if (body.trim() === "") return [];
|
|
309
|
+
const out: string[] = [];
|
|
310
|
+
// Only VERDICT lines and dedicated finding-count <summary> sections count — never descriptive
|
|
311
|
+
// prose (a clean review's file table may legitimately say `Recognizes "Changes recommended"`).
|
|
312
|
+
// A verdict line STARTS with the verdict (`### 🟡 Changes recommended`, `🟡 Changes recommended`).
|
|
313
|
+
if (/^[ \t]*(?:#{1,6}[ \t]*)?(?:🟡[ \t]*)?changes recommended\b/imu.test(body)) out.push("changes recommended");
|
|
314
|
+
// Each <summary> match is bounded by its own closing tag ([^<]* cannot cross `</summary>`).
|
|
315
|
+
for (const m of body.matchAll(/<summary>(?:<strong>)?\s*(\d+)\s+open\s+issues?\s*(?:<\/strong>)?\s*<\/summary>/gi)) {
|
|
316
|
+
if (Number(m[1]) > 0) out.push(`${m[1]} open issue${m[1] === "1" ? "" : "s"}`);
|
|
317
|
+
}
|
|
318
|
+
for (const m of body.matchAll(/<summary>(?:<strong>)?\s*Open\s*\((\d+)\)\s*(?:<\/strong>)?\s*<\/summary>/gi)) {
|
|
319
|
+
if (Number(m[1]) > 0) out.push(`${m[1]} open finding${m[1] === "1" ? "" : "s"}`);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
293
324
|
/** A finding clause's trailing `(<severity>, N vote[s])` tag in the ccr-overview-v2 file table. */
|
|
294
325
|
const V2_FINDING_TAG = /\s*\(\s*[a-z]+\s*,\s*\d+\s+votes?\s*\)/gi;
|
|
295
326
|
|
|
@@ -1665,6 +1696,10 @@ export type MergeMethod = "squash" | "merge" | "rebase";
|
|
|
1665
1696
|
export interface MergeOptions {
|
|
1666
1697
|
method: MergeMethod;
|
|
1667
1698
|
admin: boolean;
|
|
1699
|
+
/** The head SHA the caller verified (issue #923 backstop). When set, GitHub refuses the merge if
|
|
1700
|
+
* the PR head moved since — gh `--match-head-commit`, REST `sha` — so a later, unreviewed head
|
|
1701
|
+
* can never be landed by a check-then-merge race. The refusal surfaces as a transient `retry`. */
|
|
1702
|
+
expectedHeadSha?: string;
|
|
1668
1703
|
}
|
|
1669
1704
|
export interface MergeResult {
|
|
1670
1705
|
outcome: "merged" | "queued" | "blocked" | "retry";
|
|
@@ -1677,9 +1712,21 @@ export interface MergeResult {
|
|
|
1677
1712
|
* must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across
|
|
1678
1713
|
* both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact
|
|
1679
1714
|
* "<Base|Head> branch was modified" phrase — so a genuine block (conflict, failing required check,
|
|
1680
|
-
* 403 perms, 422 not-mergeable) is never swallowed as transient.
|
|
1715
|
+
* 403 perms, 422 not-mergeable) is never swallowed as transient.
|
|
1716
|
+
*
|
|
1717
|
+
* The same class covers the `--match-head-commit` / REST `sha` pin refusal (issue #923 review): the
|
|
1718
|
+
* PR head advanced past the verified head between the review backstop and the merge mutation, so
|
|
1719
|
+
* GitHub refused the pinned merge. That is a check-then-act race the loop is designed to absorb —
|
|
1720
|
+
* the bounded merge retry re-runs the review backstop on the new head — not a human-actionable
|
|
1721
|
+
* block. gh words it "Head commit of pull request #… does not match '--match-head-commit'"; the
|
|
1722
|
+
* REST `sha` mismatch surfaces as a 409 whose body carries the same "Head branch was modified"
|
|
1723
|
+
* phrase (matched by the first alternative) or a "SHA does not match" wording. */
|
|
1681
1724
|
export function isTransientMergeRace(detail: string): boolean {
|
|
1682
|
-
return
|
|
1725
|
+
return (
|
|
1726
|
+
/\b(?:base|head) branch was modified\b/i.test(detail) ||
|
|
1727
|
+
/does not match ['"]--match-head-commit['"]/i.test(detail) ||
|
|
1728
|
+
/\bsha does not match\b/i.test(detail)
|
|
1729
|
+
);
|
|
1683
1730
|
}
|
|
1684
1731
|
|
|
1685
1732
|
/** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
|
|
@@ -1697,6 +1744,7 @@ export async function mergePr(
|
|
|
1697
1744
|
if (await useGh()) {
|
|
1698
1745
|
const args = ["pr", "merge", String(number), "--repo", repo, methodFlag];
|
|
1699
1746
|
if (opts.admin) args.push("--admin");
|
|
1747
|
+
if (opts.expectedHeadSha) args.push("--match-head-commit", opts.expectedHeadSha);
|
|
1700
1748
|
try {
|
|
1701
1749
|
const out = await runGh(args);
|
|
1702
1750
|
// gh prints "… will be added to the merge queue" when the branch requires one.
|
|
@@ -1721,7 +1769,9 @@ export async function mergePr(
|
|
|
1721
1769
|
accept: "application/vnd.github+json",
|
|
1722
1770
|
"content-type": "application/json",
|
|
1723
1771
|
},
|
|
1724
|
-
body: JSON.stringify(
|
|
1772
|
+
body: JSON.stringify(
|
|
1773
|
+
opts.expectedHeadSha ? { merge_method: opts.method, sha: opts.expectedHeadSha } : { merge_method: opts.method },
|
|
1774
|
+
),
|
|
1725
1775
|
});
|
|
1726
1776
|
if (r.ok) {
|
|
1727
1777
|
// A 2xx from the REST merge endpoint does not guarantee the PR has *landed*: the body's
|
|
@@ -688,6 +688,18 @@ test("a retry that exhausts the budget escalates as a repeated race, not a gener
|
|
|
688
688
|
assertStringIncludes(String(escalation(engine).question ?? ""), "retry budget", "the retry escalation must read as a repeated race");
|
|
689
689
|
});
|
|
690
690
|
|
|
691
|
+
test("a worker-supplied block question (review backstop #923) reaches the human escalation verbatim", async () => {
|
|
692
|
+
const engine = await boot({
|
|
693
|
+
responses: { "pr.merge": { mergeStatus: "blocked", status: "blocked", question: "Merge refused: no head-current Copilot review exists." } },
|
|
694
|
+
});
|
|
695
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
696
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
697
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
698
|
+
const q = String(escalation(engine).question ?? "");
|
|
699
|
+
assertStringIncludes(q, "no head-current Copilot review", "the backstop's recovery instructions must reach the human");
|
|
700
|
+
assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
|
|
701
|
+
});
|
|
702
|
+
|
|
691
703
|
// ---------------------------------------------------------------------------
|
|
692
704
|
// CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
|
|
693
705
|
// ---------------------------------------------------------------------------
|
package/app/mergeRetry.test.ts
CHANGED
|
@@ -43,6 +43,33 @@ test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)",
|
|
|
43
43
|
assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// #923 review (Copilot "previously missed"): the `--match-head-commit` / REST `sha` pin refuses the
|
|
47
|
+
// merge when the PR head advanced past the verified head between the review backstop and the merge
|
|
48
|
+
// mutation. That refusal is the SAME class as the base/head-moved race — a transient check-then-act
|
|
49
|
+
// race GitHub resolves by re-attempting — so it must classify as `retry` (the merge loop's bounded
|
|
50
|
+
// retry re-runs the review backstop on the new head), never `blocked` (which pages a human for a
|
|
51
|
+
// race the loop is designed to absorb).
|
|
52
|
+
test("isTransientMergeRace: the pinned-head mismatch refusal is transient", () => {
|
|
53
|
+
// gh CLI `--match-head-commit` refusal (gh's own wording).
|
|
54
|
+
assertEquals(
|
|
55
|
+
isTransientMergeRace(
|
|
56
|
+
"Head commit of pull request #924 does not match '--match-head-commit' expected f5a6f20",
|
|
57
|
+
),
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
// gh wording variant (no "expected" tail).
|
|
61
|
+
assertEquals(
|
|
62
|
+
isTransientMergeRace("Head commit of pull request #924 does not match '--match-head-commit'"),
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
// REST PUT /pulls/{n}/merge `sha` mismatch — GitHub returns 409 Conflict.
|
|
66
|
+
assertEquals(
|
|
67
|
+
isTransientMergeRace("github 409 Conflict: Head branch was modified. Review and try the merge again."),
|
|
68
|
+
true,
|
|
69
|
+
);
|
|
70
|
+
assertEquals(isTransientMergeRace("github 409 Conflict: SHA does not match expected head"), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
46
73
|
// Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
|
|
47
74
|
// a genuine 405 refusal stays `blocked`.
|
|
48
75
|
async function withMergePut(
|
|
@@ -88,3 +115,25 @@ test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as tra
|
|
|
88
115
|
assertEquals(res?.outcome, "blocked");
|
|
89
116
|
});
|
|
90
117
|
});
|
|
118
|
+
|
|
119
|
+
test("mergePr: a REST 409 sha-mismatch refusal → outcome 'retry' (not blocked)", async () => {
|
|
120
|
+
await withMergePut(409, "Conflict", "Head branch was modified. Review and try the merge again.", async () => {
|
|
121
|
+
const res = await mergePr("acme/widgets", 42, "test-token", {
|
|
122
|
+
method: "squash",
|
|
123
|
+
admin: false,
|
|
124
|
+
expectedHeadSha: "f5a6f20",
|
|
125
|
+
});
|
|
126
|
+
assertEquals(res?.outcome, "retry");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("mergePr: a genuine 409 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
|
|
131
|
+
await withMergePut(409, "Conflict", "Pull Request is not mergeable", async () => {
|
|
132
|
+
const res = await mergePr("acme/widgets", 42, "test-token", {
|
|
133
|
+
method: "squash",
|
|
134
|
+
admin: false,
|
|
135
|
+
expectedHeadSha: "f5a6f20",
|
|
136
|
+
});
|
|
137
|
+
assertEquals(res?.outcome, "blocked");
|
|
138
|
+
});
|
|
139
|
+
});
|
package/app/reviewWait.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
10
10
|
DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
11
11
|
isoDurationToMs,
|
|
12
|
+
isReviewMissingOrStale,
|
|
12
13
|
isReviewStale,
|
|
13
14
|
MAX_REVIEW_NUDGE_MINUTES,
|
|
14
15
|
reviewWaitTimeout,
|
|
@@ -128,3 +129,39 @@ test("isReviewStale: a missing HEAD sha fails safe to NOT stale", () => {
|
|
|
128
129
|
test("isReviewStale: both missing is NOT stale", () => {
|
|
129
130
|
assertEquals(isReviewStale(null, null), false);
|
|
130
131
|
});
|
|
132
|
+
|
|
133
|
+
// ── isReviewMissingOrStale (issue #923) ──────────────────────────────────────
|
|
134
|
+
// The CONVERGENCE-GATE variant of the staleness predicate. `isReviewStale` intentionally fails
|
|
135
|
+
// safe to "not stale" on a missing review commit_id (a review GitHub carried no `commit_id` for
|
|
136
|
+
// must not fabricate a re-solicit loop) — but that same fail-safe is a fail-OPEN when the null
|
|
137
|
+
// means "no Copilot review exists AT ALL" (nano-bpm#1443 merged with ZERO reviews). The gate's
|
|
138
|
+
// precondition is a HEAD-CURRENT reviewer verdict, so a missing review must route to the review
|
|
139
|
+
// wait exactly like a stale one.
|
|
140
|
+
|
|
141
|
+
test("isReviewMissingOrStale: NO review (null commit_id) with a readable HEAD is stale/missing — fail CLOSED", () => {
|
|
142
|
+
// The #923 case: `readReview` verified "no Copilot review" (`commitId: null`) and the head read
|
|
143
|
+
// succeeded — the gate must NOT treat "no review" as "a verified clean review".
|
|
144
|
+
assertEquals(isReviewMissingOrStale(null, "bbb2222"), true);
|
|
145
|
+
assertEquals(isReviewMissingOrStale(undefined, "bbb2222"), true);
|
|
146
|
+
assertEquals(isReviewMissingOrStale("", "bbb2222"), true);
|
|
147
|
+
assertEquals(isReviewMissingOrStale(" ", "bbb2222"), true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("isReviewMissingOrStale: a differing review commit_id and HEAD sha is stale (same as isReviewStale)", () => {
|
|
151
|
+
assertEquals(isReviewMissingOrStale("aaa1111", "bbb2222"), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("isReviewMissingOrStale: a head-current review is NOT stale/missing", () => {
|
|
155
|
+
assertEquals(isReviewMissingOrStale("aaa1111", "aaa1111"), false);
|
|
156
|
+
assertEquals(isReviewMissingOrStale(" aaa1111 ", "aaa1111"), false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("isReviewMissingOrStale: an UNREADABLE head fails safe to NOT stale (never fabricates a re-solicit loop)", () => {
|
|
160
|
+
// Without the head we cannot prove the review predates it NOR that a missing review should
|
|
161
|
+
// re-solicit — a transport hiccup must not strand the loop re-requesting forever (the same
|
|
162
|
+
// fail-safe `isReviewStale` keeps; the review-wait timeout remains the backstop).
|
|
163
|
+
assertEquals(isReviewMissingOrStale(null, null), false);
|
|
164
|
+
assertEquals(isReviewMissingOrStale("aaa1111", null), false);
|
|
165
|
+
assertEquals(isReviewMissingOrStale("aaa1111", ""), false);
|
|
166
|
+
assertEquals(isReviewMissingOrStale(null, ""), false);
|
|
167
|
+
});
|
package/app/reviewWait.ts
CHANGED
|
@@ -114,3 +114,29 @@ export function isReviewStale(
|
|
|
114
114
|
if (rev === "" || head === "") return false;
|
|
115
115
|
return rev !== head;
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Is there NO head-current Copilot review to gate on? (issue #923) — the CONVERGENCE-GATE /
|
|
119
|
+
* merge-backstop variant of {@link isReviewStale}.
|
|
120
|
+
*
|
|
121
|
+
* `isReviewStale` intentionally fails safe to "not stale" on a missing review `commit_id` — for
|
|
122
|
+
* the POLLER, a review GitHub carried no `commit_id` for must not fabricate a re-solicit loop. But
|
|
123
|
+
* the gate's consumer is different: `fetchLatestCopilotReview` returns `commitId: null` both when
|
|
124
|
+
* no Copilot review exists AT ALL and when the newest one lacks a `commit_id`, and the gate's
|
|
125
|
+
* precondition is a HEAD-CURRENT reviewer verdict. Treating "no review" as "a verified clean
|
|
126
|
+
* review" is the fail-open that merged nano-bpm#1443 with ZERO reviews — so for the GATE, a
|
|
127
|
+
* missing review commit is stale/missing, not current.
|
|
128
|
+
*
|
|
129
|
+
* The head-side fail-safe is kept: with an UNREADABLE head we can prove nothing, so we must not
|
|
130
|
+
* fabricate a re-solicit loop on a transport hiccup (the review-wait timeout remains the
|
|
131
|
+
* backstop). Only a READABLE head + a missing/mismatched review commit routes to the review wait. */
|
|
132
|
+
export function isReviewMissingOrStale(
|
|
133
|
+
reviewCommitId: string | null | undefined,
|
|
134
|
+
headSha: string | null | undefined,
|
|
135
|
+
): boolean {
|
|
136
|
+
const head = (headSha ?? "").trim();
|
|
137
|
+
if (head === "") return false; // unverifiable head → fail safe, exactly as isReviewStale
|
|
138
|
+
const rev = (reviewCommitId ?? "").trim();
|
|
139
|
+
// A missing review commit with a readable head = NO head-current verdict → stale/missing.
|
|
140
|
+
if (rev === "") return true;
|
|
141
|
+
return rev !== head;
|
|
142
|
+
}
|