@nanobpm/nano-workforce 0.205.0 → 0.205.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.test.ts +285 -1
- package/app/github.ts +213 -11
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +141 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/readiness.ts +1 -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/service.ts +139 -1
- package/app/userTasks.ts +1 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +56 -10
- 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/merge-stall-probe/worker.ts +17 -3
- package/workers/progress-check/worker.ts +40 -6
|
@@ -53,11 +53,15 @@
|
|
|
53
53
|
<nano:extend name="mergeState" type="string" />
|
|
54
54
|
<nano:extend name="failingChecks" type="integer" optional="true" />
|
|
55
55
|
<nano:extend name="failingChecksList" type="string" optional="true" />
|
|
56
|
+
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
57
|
+
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
56
58
|
</nano:shape>
|
|
57
59
|
<nano:shape id="MergeReady" name="merge-ready message payload">
|
|
58
60
|
<nano:extend name="mergeState" type="string" />
|
|
59
61
|
<nano:extend name="failingChecks" type="integer" optional="true" />
|
|
60
62
|
<nano:extend name="failingChecksList" type="string" optional="true" />
|
|
63
|
+
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
64
|
+
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
61
65
|
</nano:shape>
|
|
62
66
|
<nano:shape id="FixCiIn" name="CI fix — input">
|
|
63
67
|
<nano:extend name="prKey" type="string" />
|
|
@@ -179,6 +183,7 @@
|
|
|
179
183
|
<bpmn:outgoing>f_m_mRebase</bpmn:outgoing>
|
|
180
184
|
<bpmn:outgoing>f_m_mWaiting</bpmn:outgoing>
|
|
181
185
|
<bpmn:outgoing>f_m_mDraft</bpmn:outgoing>
|
|
186
|
+
<bpmn:outgoing>f_m_mThreads</bpmn:outgoing>
|
|
182
187
|
<bpmn:outgoing>f_m_mBlocked</bpmn:outgoing>
|
|
183
188
|
</bpmn:exclusiveGateway>
|
|
184
189
|
<bpmn:serviceTask id="attempt-merge" name="Merge PR">
|
|
@@ -258,7 +263,7 @@
|
|
|
258
263
|
</zeebe:properties>
|
|
259
264
|
<zeebe:ioMapping>
|
|
260
265
|
<zeebe:input source="="blocked"" target="status" />
|
|
261
|
-
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "waiting" then "GitHub never settled this PR's mergeability (still computing after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
266
|
+
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "waiting" and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with all checks green (none failing, none pending) and every review thread resolved. GitHub still reports BLOCKED, which means a required review approval (or code-owner review) is outstanding — nothing the automated loop can produce. Approve the PR (or adjust the branch-protection review requirement), then reply to retry." else if mergeState = "waiting" then "GitHub never settled this PR's mergeability (still computing or checks still pending after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
262
267
|
</zeebe:ioMapping>
|
|
263
268
|
</bpmn:extensionElements>
|
|
264
269
|
<bpmn:incoming>f_m_mDraft</bpmn:incoming>
|
|
@@ -277,7 +282,7 @@
|
|
|
277
282
|
</zeebe:properties>
|
|
278
283
|
<zeebe:ioMapping>
|
|
279
284
|
<zeebe:input source="=status" target="agentVerdict" />
|
|
280
|
-
<zeebe:input source="="This PR cannot be merged and needs a human decision. " + (if mergeState = "conflict" then "The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry." else if mergeStatus = "retry" then "The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (" + string(mergeRetryMax) + " retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving." else if mergeState = "ready" then "The merge attempt did not land (merge result: " + mergeStatus + "). Investigate why GitHub refused the merge, then reply to retry." else if status = "blocked" then "The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else "The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.")" target="question" />
|
|
285
|
+
<zeebe:input source="="This PR cannot be merged and needs a human decision. " + (if mergeState = "conflict" then "The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry." else if mergeStatus = "retry" then "The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (" + string(mergeRetryMax) + " retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving." else if mergeState = "ready" and mergeStatus = "blocked" and question != null then question else if mergeState = "ready" then "The merge attempt did not land (merge result: " + mergeStatus + "). Investigate why GitHub refused the merge, then reply to retry." else if status = "blocked" then "The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else "The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.")" target="question" />
|
|
281
286
|
<zeebe:input source="="blocked"" target="status" />
|
|
282
287
|
</zeebe:ioMapping>
|
|
283
288
|
</bpmn:extensionElements>
|
|
@@ -287,9 +292,26 @@
|
|
|
287
292
|
<bpmn:incoming>f_mr_giveup</bpmn:incoming>
|
|
288
293
|
<bpmn:outgoing>f_m_escA</bpmn:outgoing>
|
|
289
294
|
</bpmn:serviceTask>
|
|
295
|
+
<bpmn:serviceTask id="merge-esc-threads" name="Escalate: unresolved review threads">
|
|
296
|
+
<bpmn:extensionElements>
|
|
297
|
+
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
298
|
+
<zeebe:properties>
|
|
299
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
300
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
301
|
+
</zeebe:properties>
|
|
302
|
+
<zeebe:ioMapping>
|
|
303
|
+
<zeebe:input source="="blocked"" target="status" />
|
|
304
|
+
<zeebe:input source="="This PR is merge-blocked by " + string(reviewThreads) + " unresolved review thread(s) (branch protection requires every conversation resolved; all checks are green and there is no conflict). These are review findings the loop already converged, so a fresh review round cannot clear them without a reply. Resolve the threads on the PR (or reply with guidance — e.g. which threads to push back on), then reply to retry."" target="question" />
|
|
305
|
+
<zeebe:input source="=null" target="agentSlaJobType" />
|
|
306
|
+
</zeebe:ioMapping>
|
|
307
|
+
</bpmn:extensionElements>
|
|
308
|
+
<bpmn:incoming>f_m_mThreads</bpmn:incoming>
|
|
309
|
+
<bpmn:outgoing>f_m_escT</bpmn:outgoing>
|
|
310
|
+
</bpmn:serviceTask>
|
|
290
311
|
<bpmn:exclusiveGateway id="gw-merge-escalated" name="escalation opened?" default="f_m_escReenter">
|
|
291
312
|
<bpmn:incoming>f_m_escA</bpmn:incoming>
|
|
292
313
|
<bpmn:incoming>f_m_escLanded</bpmn:incoming>
|
|
314
|
+
<bpmn:incoming>f_m_escT</bpmn:incoming>
|
|
293
315
|
<bpmn:outgoing>f_m_escWait</bpmn:outgoing>
|
|
294
316
|
<bpmn:outgoing>f_m_escReenter</bpmn:outgoing>
|
|
295
317
|
</bpmn:exclusiveGateway>
|
|
@@ -732,6 +754,9 @@
|
|
|
732
754
|
<bpmn:sequenceFlow id="f_m_mDraft" name="draft — mark ready (human)" sourceRef="gw-mergeable" targetRef="merge-esc-conflict">
|
|
733
755
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeState = "draft"</bpmn:conditionExpression>
|
|
734
756
|
</bpmn:sequenceFlow>
|
|
757
|
+
<bpmn:sequenceFlow id="f_m_mThreads" name="blocked (unresolved review threads)" sourceRef="gw-mergeable" targetRef="merge-esc-threads">
|
|
758
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeState = "review-threads"</bpmn:conditionExpression>
|
|
759
|
+
</bpmn:sequenceFlow>
|
|
735
760
|
<bpmn:sequenceFlow id="f_mrp_probe" sourceRef="wait-mergeable-repoll" targetRef="merge-stall-probe" />
|
|
736
761
|
<bpmn:sequenceFlow id="f_m_mBlocked" name="unclassified — auto-rebase" sourceRef="gw-mergeable" targetRef="gw-rebase" />
|
|
737
762
|
<bpmn:sequenceFlow id="f_ci_go" name="within budget" sourceRef="gw-ci-fix" targetRef="SP_cifix">
|
|
@@ -773,6 +798,7 @@
|
|
|
773
798
|
<bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
|
|
774
799
|
<bpmn:sequenceFlow id="f_m_escC" sourceRef="merge-esc-conflict" targetRef="wait-merge-answer" />
|
|
775
800
|
<bpmn:sequenceFlow id="f_m_escA" sourceRef="merge-esc-attempt" targetRef="gw-merge-escalated" />
|
|
801
|
+
<bpmn:sequenceFlow id="f_m_escT" sourceRef="merge-esc-threads" targetRef="gw-merge-escalated" />
|
|
776
802
|
<bpmn:sequenceFlow id="f_m_escWait" sourceRef="gw-merge-escalated" targetRef="wait-merge-answer">
|
|
777
803
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=escalated = true</bpmn:conditionExpression>
|
|
778
804
|
</bpmn:sequenceFlow>
|
|
@@ -833,7 +859,7 @@
|
|
|
833
859
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-stall" bpmnElement="gw-merge-stall" isMarkerVisible="true">
|
|
834
860
|
<dc:Bounds x="1538" y="1535" width="50" height="50" />
|
|
835
861
|
<bpmndi:BPMNLabel>
|
|
836
|
-
<dc:Bounds x="1521" y="
|
|
862
|
+
<dc:Bounds x="1521" y="1630" width="85" height="42" />
|
|
837
863
|
</bpmndi:BPMNLabel>
|
|
838
864
|
</bpmndi:BPMNShape>
|
|
839
865
|
<bpmndi:BPMNShape id="BPMNShape_gw-mergeable" bpmnElement="gw-mergeable" isMarkerVisible="true">
|
|
@@ -902,6 +928,9 @@
|
|
|
902
928
|
<bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
|
|
903
929
|
<dc:Bounds x="1688" y="720" width="100" height="80" />
|
|
904
930
|
</bpmndi:BPMNShape>
|
|
931
|
+
<bpmndi:BPMNShape id="BPMNShape_merge-esc-threads" bpmnElement="merge-esc-threads">
|
|
932
|
+
<dc:Bounds x="1138" y="1680" width="100" height="80" />
|
|
933
|
+
</bpmndi:BPMNShape>
|
|
905
934
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
|
|
906
935
|
<dc:Bounds x="2088" y="95" width="50" height="50" />
|
|
907
936
|
<bpmndi:BPMNLabel>
|
|
@@ -1002,7 +1031,7 @@
|
|
|
1002
1031
|
<di:waypoint x="402" y="1300" />
|
|
1003
1032
|
<di:waypoint x="402" y="320" />
|
|
1004
1033
|
<bpmndi:BPMNLabel>
|
|
1005
|
-
<dc:Bounds x="
|
|
1034
|
+
<dc:Bounds x="919" y="1239" width="88" height="56" />
|
|
1006
1035
|
</bpmndi:BPMNLabel>
|
|
1007
1036
|
</bpmndi:BPMNEdge>
|
|
1008
1037
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_rearm" bpmnElement="f_reb_rearm">
|
|
@@ -1179,6 +1208,14 @@
|
|
|
1179
1208
|
<dc:Bounds x="1057" y="483" width="85" height="42" />
|
|
1180
1209
|
</bpmndi:BPMNLabel>
|
|
1181
1210
|
</bpmndi:BPMNEdge>
|
|
1211
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_mThreads" bpmnElement="f_m_mThreads">
|
|
1212
|
+
<di:waypoint x="1013" y="305" />
|
|
1213
|
+
<di:waypoint x="1013" y="1720" />
|
|
1214
|
+
<di:waypoint x="1138" y="1720" />
|
|
1215
|
+
<bpmndi:BPMNLabel>
|
|
1216
|
+
<dc:Bounds x="1018" y="1642" width="81" height="56" />
|
|
1217
|
+
</bpmndi:BPMNLabel>
|
|
1218
|
+
</bpmndi:BPMNEdge>
|
|
1182
1219
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_mBlocked" bpmnElement="f_m_mBlocked">
|
|
1183
1220
|
<di:waypoint x="1013" y="305" />
|
|
1184
1221
|
<di:waypoint x="1013" y="440" />
|
|
@@ -1255,7 +1292,7 @@
|
|
|
1255
1292
|
<di:waypoint x="1668" y="740" />
|
|
1256
1293
|
<di:waypoint x="1688" y="740" />
|
|
1257
1294
|
<bpmndi:BPMNLabel>
|
|
1258
|
-
<dc:Bounds x="
|
|
1295
|
+
<dc:Bounds x="1932" y="616" width="53" height="14" />
|
|
1259
1296
|
</bpmndi:BPMNLabel>
|
|
1260
1297
|
</bpmndi:BPMNEdge>
|
|
1261
1298
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
|
|
@@ -1281,8 +1318,9 @@
|
|
|
1281
1318
|
<di:waypoint x="870" y="618" />
|
|
1282
1319
|
<di:waypoint x="870" y="638" />
|
|
1283
1320
|
<di:waypoint x="888" y="638" />
|
|
1284
|
-
<di:waypoint x="888" y="
|
|
1285
|
-
<di:waypoint x="
|
|
1321
|
+
<di:waypoint x="888" y="1780" />
|
|
1322
|
+
<di:waypoint x="1388" y="1780" />
|
|
1323
|
+
<di:waypoint x="1388" y="1600" />
|
|
1286
1324
|
</bpmndi:BPMNEdge>
|
|
1287
1325
|
<bpmndi:BPMNEdge id="BPMNEdge_f_msp_gate" bpmnElement="f_msp_gate">
|
|
1288
1326
|
<di:waypoint x="1438" y="1560" />
|
|
@@ -1308,6 +1346,14 @@
|
|
|
1308
1346
|
<di:waypoint x="1988" y="120" />
|
|
1309
1347
|
<di:waypoint x="2088" y="120" />
|
|
1310
1348
|
</bpmndi:BPMNEdge>
|
|
1349
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escT" bpmnElement="f_m_escT">
|
|
1350
|
+
<di:waypoint x="1238" y="1720" />
|
|
1351
|
+
<di:waypoint x="2008" y="1720" />
|
|
1352
|
+
<di:waypoint x="2008" y="145" />
|
|
1353
|
+
<di:waypoint x="2068" y="145" />
|
|
1354
|
+
<di:waypoint x="2068" y="120" />
|
|
1355
|
+
<di:waypoint x="2088" y="120" />
|
|
1356
|
+
</bpmndi:BPMNEdge>
|
|
1311
1357
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
|
|
1312
1358
|
<di:waypoint x="2138" y="120" />
|
|
1313
1359
|
<di:waypoint x="2238" y="120" />
|
|
@@ -1327,13 +1373,13 @@
|
|
|
1327
1373
|
<di:waypoint x="1013" y="1620" />
|
|
1328
1374
|
<di:waypoint x="1013" y="305" />
|
|
1329
1375
|
<bpmndi:BPMNLabel>
|
|
1330
|
-
<dc:Bounds x="
|
|
1376
|
+
<dc:Bounds x="1246" y="1587" width="85" height="28" />
|
|
1331
1377
|
</bpmndi:BPMNLabel>
|
|
1332
1378
|
</bpmndi:BPMNEdge>
|
|
1333
1379
|
<bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
|
|
1334
1380
|
<di:waypoint x="1738" y="1280" />
|
|
1335
|
-
<di:waypoint x="1738" y="
|
|
1336
|
-
<di:waypoint x="234" y="
|
|
1381
|
+
<di:waypoint x="1738" y="1300" />
|
|
1382
|
+
<di:waypoint x="234" y="1300" />
|
|
1337
1383
|
<di:waypoint x="234" y="298" />
|
|
1338
1384
|
</bpmndi:BPMNEdge>
|
|
1339
1385
|
<bpmndi:BPMNEdge id="BPMNEdge_f_mr_go" bpmnElement="f_mr_go">
|
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
// re-enters the review wait for a fresh review of the current HEAD. The head read fails OPEN, so a
|
|
30
30
|
// transport hiccup can never fabricate a stale verdict.
|
|
31
31
|
//
|
|
32
|
+
// MISSING-REVIEW GUARD (issue #923): the same `reviewStale` signal fires when NO Copilot review
|
|
33
|
+
// exists at all — the gate's single most important precondition. The old code treated
|
|
34
|
+
// `{ body: "", commitId: null }` as "a verified clean review" and converged, merging
|
|
35
|
+
// nano-bpm#1443 with ZERO reviews. A missing review now fails CLOSED into the re-solicit path.
|
|
36
|
+
//
|
|
37
|
+
// VERDICT GATE (issue #923 §2): even a head-current review BLOCKS when its own summary reports
|
|
38
|
+
// open, actionable findings ("Changes recommended" / "N open issue(s)" / a v2 "Open (N)" section)
|
|
39
|
+
// — zero unresolved threads is not a clean verdict.
|
|
40
|
+
//
|
|
32
41
|
// Scope integrity is NO LONGER judged here. A deterministic regex over the PR description could not
|
|
33
42
|
// read the closed issue's acceptance criteria, so it false-positived on any body that merely
|
|
34
43
|
// *mentioned* deferral (an ADR non-goal, a PR whose subject is scope tooling) and forced needless
|
|
@@ -49,10 +58,11 @@ import {
|
|
|
49
58
|
fetchReviewThreads,
|
|
50
59
|
isAckThread,
|
|
51
60
|
parseAckedAdvisories,
|
|
61
|
+
parseReviewOpenFindings,
|
|
52
62
|
parseSuppressedAdvisories,
|
|
53
63
|
type ReviewThread,
|
|
54
64
|
} from "../../app/github.ts";
|
|
55
|
-
import {
|
|
65
|
+
import { isReviewMissingOrStale } from "../../app/reviewWait.ts";
|
|
56
66
|
import { parsePr } from "../../app/service.ts";
|
|
57
67
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
58
68
|
import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
|
|
@@ -118,17 +128,57 @@ export function makeHandler(deps: {
|
|
|
118
128
|
if (review === null) {
|
|
119
129
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
|
|
120
130
|
}
|
|
121
|
-
// STALE-REVIEW GUARD (
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
131
|
+
// STALE-OR-MISSING-REVIEW GUARD (issues #799 / #923). Convergence requires a HEAD-CURRENT
|
|
132
|
+
// reviewer verdict. Two states fail that precondition and must route to the review wait (the
|
|
133
|
+
// poller (re-)solicits a review of the current HEAD) rather than be gated on:
|
|
134
|
+
// • STALE (#799): the PR HEAD has advanced PAST the commit the latest Copilot review was
|
|
135
|
+
// submitted against — its suppressed advisories describe code the head has moved past
|
|
136
|
+
// (e.g. an advisory the agent already FIXED IN CODE but did not ack), so blocking on the
|
|
137
|
+
// obsolete body re-escalates a human indefinitely (PR #789).
|
|
138
|
+
// • MISSING (#923): NO Copilot review exists at all (`commitId: null` with a readable
|
|
139
|
+
// head). The old gate treated that as "a verified clean review" — zero threads, zero
|
|
140
|
+
// advisories → not blocked — and merged nano-bpm#1443 with ZERO reviews. A missing
|
|
141
|
+
// review is the gate's single most important precondition, so it fails CLOSED into the
|
|
142
|
+
// same re-solicit path (`isReviewMissingOrStale`), never through to finalize.
|
|
143
|
+
// The head read still fails OPEN (null → not stale): without the head we can prove nothing,
|
|
144
|
+
// so a transport hiccup can never fabricate a stale/missing verdict that would loop the loop
|
|
145
|
+
// re-soliciting forever — the review-wait timeout remains the backstop.
|
|
146
|
+
//
|
|
147
|
+
// ARRIVAL-WINDOW CUTOFF (issue #923 review, "previously missed"): the stale/missing verdict
|
|
148
|
+
// is decided from the review read ABOVE — but the loop only PARKS for the next review several
|
|
149
|
+
// tasks later (persist-round → check-progress), and check-progress stamps `waiting_since` at
|
|
150
|
+
// THAT (later) park time. A head-current review that lands in the window BETWEEN this read
|
|
151
|
+
// and the park would then be REJECTED by `pollReviews` (it filters
|
|
152
|
+
// `submitted_at >= waiting_since`), so the loop re-solicits and can time out despite a valid
|
|
153
|
+
// verdict already sitting unconsumed on the PR. Emit the time of THIS read as
|
|
154
|
+
// `reviewWaitCutoff` so check-progress can park with `waiting_since` at the cutoff — any
|
|
155
|
+
// review that arrived in the window then satisfies the wait instead of being discarded.
|
|
129
156
|
const headSha = await readHeadSha(ghRepo, ghNumber).catch(() => null);
|
|
130
|
-
if (
|
|
131
|
-
return {
|
|
157
|
+
if (isReviewMissingOrStale(review.commitId, headSha)) {
|
|
158
|
+
return {
|
|
159
|
+
convergeBlocked: false,
|
|
160
|
+
convergeBlockReason: "",
|
|
161
|
+
convergeAckOnly: false,
|
|
162
|
+
reviewStale: true,
|
|
163
|
+
reviewWaitCutoff: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// VERDICT GATE (issue #923 §2): a head-current review whose OWN summary reports open,
|
|
167
|
+
// actionable findings (Copilot's "Changes recommended" / "N open issue(s)" / a v2 "Open (N)"
|
|
168
|
+
// section) blocks the same way an unresolved thread does — even with zero unresolved threads
|
|
169
|
+
// (a review can carry findings as summary-only, as #1443's 7-finding review did). This block
|
|
170
|
+
// is NOT ack-only: the findings need the round agent's code/reply work, so it escalates to a
|
|
171
|
+
// human rather than entering the bounded auto-ack path.
|
|
172
|
+
const openFindings = parseReviewOpenFindings(review.body);
|
|
173
|
+
if (openFindings.length > 0) {
|
|
174
|
+
return {
|
|
175
|
+
convergeBlocked: true,
|
|
176
|
+
convergeBlockReason:
|
|
177
|
+
`Convergence blocked: the latest head-current Copilot review reports ${openFindings.join("; ")}. ` +
|
|
178
|
+
`Address every finding the review raised (or reply-and-resolve why each is a false positive) before converging.`,
|
|
179
|
+
convergeAckOnly: false,
|
|
180
|
+
reviewStale: false,
|
|
181
|
+
};
|
|
132
182
|
}
|
|
133
183
|
// Split the unresolved threads into SUBSTANTIVE reviewer findings vs partially-completed
|
|
134
184
|
// `nano-ack:` ACK threads (root carries a canonical `nano-ack: <path> :: <text>` marker — see
|
|
@@ -8,7 +8,7 @@ import { test } from "node:test";
|
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
9
|
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
10
10
|
import { noopLog } from "../../test/log.ts";
|
|
11
|
-
import handler from "./worker.ts";
|
|
11
|
+
import handler, { makeHandler } from "./worker.ts";
|
|
12
12
|
|
|
13
13
|
function fakeApp() {
|
|
14
14
|
const stores: Record<string, Record<string, unknown>[]> = {
|
|
@@ -294,3 +294,194 @@ test("pr.merge abandons a closed-not-merged PR without escalating (terminal aban
|
|
|
294
294
|
},
|
|
295
295
|
);
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
// ── Merge-loop backstop (issue #923 §3) ──────────────────────────────────────
|
|
299
|
+
// Just before landing, the worker re-checks that a HEAD-CURRENT Copilot review exists. A head that
|
|
300
|
+
// moved between convergence and landing — or a review that never existed (the nano-bpm#1443
|
|
301
|
+
// fail-open: merged with ZERO reviews) — must block the landing, not slip through. These tests
|
|
302
|
+
// drive the backstop through `makeHandler`'s injected readers; the fetch stub only needs the
|
|
303
|
+
// single-PR GET (liveness) since the injected readers replace the review/head fetches.
|
|
304
|
+
|
|
305
|
+
function backstopRoutes(url: string): Response | null {
|
|
306
|
+
if (/\/pulls\/\d+$/.test(url)) {
|
|
307
|
+
return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
|
|
308
|
+
}
|
|
309
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
test("pr.merge #923: refuses to land when NO Copilot review exists (the #1443 fail-open)", async () => {
|
|
313
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
314
|
+
const { app, stores } = fakeApp();
|
|
315
|
+
const backstop = makeHandler({
|
|
316
|
+
// Verified "no Copilot review on this PR" + a readable head → missing → BLOCK.
|
|
317
|
+
readReview: async () => ({ body: "", commitId: null }),
|
|
318
|
+
readHeadSha: async () => "headsha4444444444444444444444444444444444",
|
|
319
|
+
});
|
|
320
|
+
const out = (await backstop(
|
|
321
|
+
{ variables: { prKey: "acme/widgets#21", repo: "acme/widgets", prNumber: 21 } } as any,
|
|
322
|
+
app,
|
|
323
|
+
)) as Record<string, unknown>;
|
|
324
|
+
|
|
325
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
326
|
+
assertEquals(out.status, "blocked");
|
|
327
|
+
assertEquals(typeof out.question, "string");
|
|
328
|
+
assertEquals((out.question as string).includes("no head-current Copilot review"), true);
|
|
329
|
+
|
|
330
|
+
// The refusal is audited, and NO land was attempted (no merge PUT, no enqueue comment).
|
|
331
|
+
assertEquals(stores.merges.length, 1);
|
|
332
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
333
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
334
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
335
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("pr.merge #923: refuses to land when the head MOVED past the latest review (stale at land time)", async () => {
|
|
340
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
341
|
+
const { app, stores } = fakeApp();
|
|
342
|
+
const backstop = makeHandler({
|
|
343
|
+
// The review was submitted against the OLD head; the branch has since advanced.
|
|
344
|
+
readReview: async () => ({ body: "lgtm", commitId: "oldsha1111111111111111111111111111111111" }),
|
|
345
|
+
readHeadSha: async () => "newsha2222222222222222222222222222222222",
|
|
346
|
+
});
|
|
347
|
+
const out = (await backstop(
|
|
348
|
+
{ variables: { prKey: "acme/widgets#23", repo: "acme/widgets", prNumber: 23 } } as any,
|
|
349
|
+
app,
|
|
350
|
+
)) as Record<string, unknown>;
|
|
351
|
+
|
|
352
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
353
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
354
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("pr.merge #923: a HEAD-CURRENT review lands normally (backstop control)", async () => {
|
|
359
|
+
await withGithub(
|
|
360
|
+
(url) => {
|
|
361
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
362
|
+
return backstopRoutes(url);
|
|
363
|
+
},
|
|
364
|
+
async (calls) => {
|
|
365
|
+
const { app, stores } = fakeApp();
|
|
366
|
+
const backstop = makeHandler({
|
|
367
|
+
readReview: async () => ({ body: "lgtm", commitId: "samesha33333333333333333333333333333333" }),
|
|
368
|
+
readHeadSha: async () => "samesha33333333333333333333333333333333",
|
|
369
|
+
});
|
|
370
|
+
const out = (await backstop(
|
|
371
|
+
{ variables: { prKey: "acme/widgets#25", repo: "acme/widgets", prNumber: 25 } } as any,
|
|
372
|
+
app,
|
|
373
|
+
)) as Record<string, unknown>;
|
|
374
|
+
|
|
375
|
+
// The backstop passes and the ordinary land protocol runs (direct merge).
|
|
376
|
+
assertEquals(out, { mergeStatus: "merged" });
|
|
377
|
+
assertEquals(
|
|
378
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
379
|
+
true,
|
|
380
|
+
);
|
|
381
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("pr.merge #923: an UNREADABLE review/head fails OPEN (a transport hiccup never wedges a merge)", async () => {
|
|
387
|
+
await withGithub(
|
|
388
|
+
(url) => {
|
|
389
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
390
|
+
return backstopRoutes(url);
|
|
391
|
+
},
|
|
392
|
+
async (calls) => {
|
|
393
|
+
const { app, stores } = fakeApp();
|
|
394
|
+
const backstop = makeHandler({
|
|
395
|
+
readReview: async () => null, // no usable transport → unverifiable, NOT "no review"
|
|
396
|
+
readHeadSha: async () => {
|
|
397
|
+
throw new Error("boom");
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
const out = (await backstop(
|
|
401
|
+
{ variables: { prKey: "acme/widgets#27", repo: "acme/widgets", prNumber: 27 } } as any,
|
|
402
|
+
app,
|
|
403
|
+
)) as Record<string, unknown>;
|
|
404
|
+
|
|
405
|
+
assertEquals(out, { mergeStatus: "merged" });
|
|
406
|
+
assertEquals(
|
|
407
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
408
|
+
true,
|
|
409
|
+
);
|
|
410
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("pr.merge #923: a head-current review whose summary reports OPEN findings blocks landing", async () => {
|
|
416
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
417
|
+
const { app, stores } = fakeApp();
|
|
418
|
+
const backstop = makeHandler({
|
|
419
|
+
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "h1" }),
|
|
420
|
+
readHeadSha: async () => "h1",
|
|
421
|
+
});
|
|
422
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app)) as Record<string, unknown>;
|
|
423
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
424
|
+
assertEquals((out.question as string).includes("open findings"), true);
|
|
425
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
426
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
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
|
+
test("pr.merge #923: the direct merge pins the verified head SHA (no check-then-merge race)", async () => {
|
|
448
|
+
let putBody = "";
|
|
449
|
+
await withGithub(
|
|
450
|
+
(url, init) => {
|
|
451
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) {
|
|
452
|
+
putBody = String(init?.body ?? "");
|
|
453
|
+
return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
454
|
+
}
|
|
455
|
+
return backstopRoutes(url);
|
|
456
|
+
},
|
|
457
|
+
async () => {
|
|
458
|
+
const { app } = fakeApp();
|
|
459
|
+
const backstop = makeHandler({ readReview: async () => ({ body: "lgtm", commitId: "h2" }), readHeadSha: async () => "h2" });
|
|
460
|
+
await backstop({ variables: { prKey: "acme/widgets#31", repo: "acme/widgets", prNumber: 31 } } as any, app);
|
|
461
|
+
assertEquals(JSON.parse(putBody).sha, "h2");
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("pr.merge #923: a queue-comment repo whose head moved after the backstop retries instead of enqueueing", async () => {
|
|
467
|
+
const protocol =
|
|
468
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
469
|
+
await withGithub(
|
|
470
|
+
(url) => {
|
|
471
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
472
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
473
|
+
return backstopRoutes(url);
|
|
474
|
+
},
|
|
475
|
+
async (calls) => {
|
|
476
|
+
const { app } = fakeApp();
|
|
477
|
+
const heads = ["h3", "h4"];
|
|
478
|
+
const backstop = makeHandler({
|
|
479
|
+
readReview: async () => ({ body: "lgtm", commitId: "h3" }),
|
|
480
|
+
readHeadSha: async () => heads.shift() ?? "h4",
|
|
481
|
+
});
|
|
482
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#33", repo: "acme/widgets", prNumber: 33 } } as any, app)) as Record<string, unknown>;
|
|
483
|
+
assertEquals(out.mergeStatus, "retry");
|
|
484
|
+
assertEquals(calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"), false);
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
});
|