@nanobpm/nano-workforce 0.208.5 → 0.209.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/behindBaseRemedy.test.ts +115 -0
- package/app/behindBaseRemedy.ts +95 -0
- package/app/github.test.ts +137 -2
- package/app/github.ts +137 -1
- package/app/mergeLoopBehaviour.test.ts +49 -0
- package/app/service.ts +7 -0
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/pages/_nav.json +12 -1
- package/pages/auto-resolutions.page.json +14 -1
- package/pages/board.page.json +14 -1
- package/pages/cockpit.page.json +14 -1
- package/pages/delivery-graph-detail.page.json +14 -1
- package/pages/delivery-graphs.page.json +14 -1
- package/pages/epic-detail.page.json +14 -1
- package/pages/epic.page.json +14 -1
- package/pages/feature.page.json +14 -1
- package/pages/home.page.json +14 -1
- package/pages/lineage.page.json +14 -1
- package/pages/mcp.page.json +14 -1
- package/pages/overview.page.json +14 -1
- package/pages/tasks.page.json +14 -1
- package/pages/velocity.page.json +14 -1
- package/resources/processes/merge-loop.bpmn +2 -1
- package/scripts/sync-nav.test.ts +38 -0
- package/workers/merge-stall-probe/worker.test.ts +119 -0
- package/workers/merge-stall-probe/worker.ts +69 -8
- package/workers/request-review/worker.test.ts +27 -0
- package/workers/request-review/worker.ts +42 -2
package/pages/_nav.json
CHANGED
|
@@ -11,7 +11,18 @@
|
|
|
11
11
|
{ "label": "Convergence", "page": "home" },
|
|
12
12
|
{ "label": "Epics", "page": "epic" },
|
|
13
13
|
{ "label": "Feature", "page": "feature" },
|
|
14
|
-
{
|
|
14
|
+
{
|
|
15
|
+
"label": "Delivery Graphs",
|
|
16
|
+
"page": "delivery-graphs",
|
|
17
|
+
"badge": {
|
|
18
|
+
"source": "app",
|
|
19
|
+
"table": "delivery_graph_proposals",
|
|
20
|
+
"filter": [{ "field": "status", "eq": "staged" }],
|
|
21
|
+
"tone": "ok",
|
|
22
|
+
"refreshMs": 5000,
|
|
23
|
+
"hideWhenZero": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
15
26
|
{
|
|
16
27
|
"label": "Tasks",
|
|
17
28
|
"page": "tasks",
|
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/board.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/cockpit.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/epic.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/feature.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/home.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/lineage.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/mcp.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/overview.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/tasks.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
package/pages/velocity.page.json
CHANGED
|
@@ -35,7 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"label": "Delivery Graphs",
|
|
38
|
-
"page": "delivery-graphs"
|
|
38
|
+
"page": "delivery-graphs",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "delivery_graph_proposals",
|
|
42
|
+
"filter": [
|
|
43
|
+
{
|
|
44
|
+
"field": "status",
|
|
45
|
+
"eq": "staged"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tone": "ok",
|
|
49
|
+
"refreshMs": 5000,
|
|
50
|
+
"hideWhenZero": true
|
|
51
|
+
}
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
"label": "Tasks",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
<nano:extend name="failingChecksList" type="string" optional="true" />
|
|
56
56
|
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
57
57
|
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
58
|
+
<nano:extend name="updatePending" type="boolean" optional="true" />
|
|
58
59
|
</nano:shape>
|
|
59
60
|
<nano:shape id="LandedProbeIn" name="Landed probe — input">
|
|
60
61
|
<nano:extend name="prKey" type="string" />
|
|
@@ -846,7 +847,7 @@
|
|
|
846
847
|
<bpmn:sequenceFlow id="f_mw_stall" sourceRef="wait-mergeable-timeout" targetRef="merge-stall-probe" />
|
|
847
848
|
<bpmn:sequenceFlow id="f_msp_gate" sourceRef="merge-stall-probe" targetRef="gw-merge-stall" />
|
|
848
849
|
<bpmn:sequenceFlow id="f_msp_rederive" name="re-derived — route to arm" sourceRef="gw-merge-stall" targetRef="gw-mergeable">
|
|
849
|
-
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStallRounds <= mergeStallMax</bpmn:conditionExpression>
|
|
850
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStallRounds <= mergeStallMax or mergeState = "ready" or updatePending = true</bpmn:conditionExpression>
|
|
850
851
|
</bpmn:sequenceFlow>
|
|
851
852
|
<bpmn:sequenceFlow id="f_msp_giveup" name="stall budget exhausted" sourceRef="gw-merge-stall" targetRef="merge-esc-conflict" />
|
|
852
853
|
<bpmn:sequenceFlow id="f_m_ready" sourceRef="wait-mergeable" targetRef="gw-mergeable" />
|
package/scripts/sync-nav.test.ts
CHANGED
|
@@ -85,3 +85,41 @@ test("issue #306: the badge's user_tasks table is defined by the migrations", ()
|
|
|
85
85
|
"badge table \"user_tasks\" must be created (TABLE or VIEW) by db/migrations/*.sql",
|
|
86
86
|
);
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test("the Delivery Graphs nav item carries the staged-proposals-awaiting-approval badge", () => {
|
|
90
|
+
const canon = JSON.parse(readFileSync(`${ROOT}pages/_nav.json`, "utf8"));
|
|
91
|
+
const items = canon?.props?.items ?? [];
|
|
92
|
+
const dg = items.find((i: Json) => i?.page === "delivery-graphs");
|
|
93
|
+
assert(dg, "canonical nav has no Delivery Graphs item");
|
|
94
|
+
const badge = dg.badge;
|
|
95
|
+
assert(badge, "Delivery Graphs nav item must declare a live count badge");
|
|
96
|
+
// Counts ONLY proposals awaiting approval (status = staged) — dropping the filter
|
|
97
|
+
// would count approved/rejected proposals too; the drift check alone can't catch that.
|
|
98
|
+
assert(badge.source === "app", "badge.source must be \"app\"");
|
|
99
|
+
assert(
|
|
100
|
+
badge.table === "delivery_graph_proposals",
|
|
101
|
+
"badge.table must be \"delivery_graph_proposals\"",
|
|
102
|
+
);
|
|
103
|
+
assert(
|
|
104
|
+
stable(badge.filter) === stable([{ field: "status", eq: "staged" }]),
|
|
105
|
+
"badge.filter must be exactly [{ field: \"status\", eq: \"staged\" }]",
|
|
106
|
+
);
|
|
107
|
+
assert(badge.tone === "ok", "badge.tone must be \"ok\" (green)");
|
|
108
|
+
assert(badge.refreshMs === 5000, "badge.refreshMs must be 5000");
|
|
109
|
+
assert(badge.hideWhenZero === true, "badge.hideWhenZero must be true");
|
|
110
|
+
|
|
111
|
+
let sql = "";
|
|
112
|
+
for (const e of readdirSync(`${ROOT}db/migrations`, { withFileTypes: true })) {
|
|
113
|
+
if (e.isFile() && e.name.endsWith(".sql")) {
|
|
114
|
+
sql += readFileSync(`${ROOT}db/migrations/${e.name}`, "utf8");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
assert(
|
|
118
|
+
/CREATE\s+(?:TABLE|VIEW)\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?delivery_graph_proposals["`]?/i.test(sql),
|
|
119
|
+
"badge table \"delivery_graph_proposals\" must be created by db/migrations/*.sql",
|
|
120
|
+
);
|
|
121
|
+
assert(
|
|
122
|
+
/delivery_graph_proposals[\s\S]*?\bstatus\b/i.test(sql),
|
|
123
|
+
"delivery_graph_proposals must have a status column",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// pr.merge-stall-probe — issue #951: the probe must re-read PR state before the merge loop can
|
|
2
|
+
// escalate. A PR that already MERGED (a human queued it while the loop waited) must surface `ready`
|
|
3
|
+
// so `gw-merge-stall` routes it to `attempt-merge` (whose already-merged short-circuit ends the loop
|
|
4
|
+
// as merged) instead of exhausting the stall budget into a "merge-blocked" escalation.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
7
|
+
import type { PrState } from "../../app/github.ts";
|
|
8
|
+
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol } from "../../app/mergeProtocol.ts";
|
|
9
|
+
import { noopLog } from "../../test/log.ts";
|
|
10
|
+
import { makeHandler } from "./worker.ts";
|
|
11
|
+
|
|
12
|
+
function state(over: Partial<PrState>): PrState {
|
|
13
|
+
return {
|
|
14
|
+
merged: false,
|
|
15
|
+
state: "open",
|
|
16
|
+
mergeStateStatus: "BLOCKED",
|
|
17
|
+
failingChecks: 0,
|
|
18
|
+
failingCheckNames: [],
|
|
19
|
+
presentCheckNames: ["build", "Mergify Merge Queue"],
|
|
20
|
+
pendingCheckNames: [],
|
|
21
|
+
checkConclusions: { build: "SUCCESS", "Mergify Merge Queue": "NEUTRAL" },
|
|
22
|
+
totalChecks: 2,
|
|
23
|
+
isDraft: false,
|
|
24
|
+
headRefOid: "abc",
|
|
25
|
+
mergeQueueEntry: null,
|
|
26
|
+
unresolvedThreads: 0,
|
|
27
|
+
...over,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MERGIFY: MergeProtocol = {
|
|
32
|
+
...DEFAULT_MERGE_PROTOCOL,
|
|
33
|
+
land: { method: "mergify-queue", comment: "@mergifyio queue" },
|
|
34
|
+
requiredChecks: [{ name: "build", acceptedConclusions: ["success"] }],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function run(st: PrState, protocol: MergeProtocol | null, isUpdatePending = false): Promise<Record<string, unknown>> {
|
|
38
|
+
const handler = makeHandler({
|
|
39
|
+
readState: () => Promise.resolve(st),
|
|
40
|
+
readProtocol: () => Promise.resolve(protocol),
|
|
41
|
+
isUpdatePending: () => isUpdatePending,
|
|
42
|
+
});
|
|
43
|
+
const job: any = { variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } };
|
|
44
|
+
const app: any = { log: noopLog() };
|
|
45
|
+
return (await handler(job, app)) as Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runNullRead(isUpdatePending = false): Promise<Record<string, unknown>> {
|
|
49
|
+
const handler = makeHandler({
|
|
50
|
+
readState: () => Promise.reject(new Error("GitHub unreachable")),
|
|
51
|
+
readProtocol: () => Promise.resolve(null),
|
|
52
|
+
isUpdatePending: () => isUpdatePending,
|
|
53
|
+
});
|
|
54
|
+
const job: any = { variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } };
|
|
55
|
+
const app: any = { log: noopLog() };
|
|
56
|
+
return (await handler(job, app)) as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("merge-stall-probe: a PR already MERGED surfaces `ready` (never an escalation) (#951)", async () => {
|
|
60
|
+
const out = await run(state({ merged: true, state: "merged", mergeStateStatus: "UNKNOWN" }), MERGIFY);
|
|
61
|
+
assertEquals(out.mergeState, "ready");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("merge-stall-probe: a CLOSED PR surfaces `ready` so attempt-merge abandons it (#951)", async () => {
|
|
65
|
+
const out = await run(state({ state: "closed", mergeStateStatus: "UNKNOWN" }), null);
|
|
66
|
+
assertEquals(out.mergeState, "ready");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("merge-stall-probe: green PR awaiting `@mergifyio queue` is `ready`, not a review stall (#951)", async () => {
|
|
70
|
+
const out = await run(state({}), MERGIFY);
|
|
71
|
+
assertEquals(out.mergeState, "ready");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("merge-stall-probe: BLOCKED gh-merge PR with green checks still waits (#951)", async () => {
|
|
75
|
+
const out = await run(state({}), { ...MERGIFY, land: { method: "gh-merge" } });
|
|
76
|
+
assertEquals(out.mergeState, "waiting");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Issue #951 review: while an accepted (async) update-branch still exposes the old head, the probe
|
|
80
|
+
// must NOT classify that head's stale check failures as `blocked` (which starts fix-ci / escalates).
|
|
81
|
+
// It defers to `waiting` (bounded re-poll) until the head moves or the remedy's window lapses.
|
|
82
|
+
test("merge-stall-probe: a blocked verdict on a pending-update old head defers to waiting (#951)", async () => {
|
|
83
|
+
const blocked = state({ failingChecks: 1, failingCheckNames: ["build"], checkConclusions: { build: "FAILURE" } });
|
|
84
|
+
// Sanity: without a pending update this IS blocked (would route to fix-ci).
|
|
85
|
+
assertEquals((await run(blocked, MERGIFY, false)).mergeState, "blocked");
|
|
86
|
+
// With the update still pending on this head, the probe holds remediation.
|
|
87
|
+
const held = await run(blocked, MERGIFY, true);
|
|
88
|
+
assertEquals(held.mergeState, "waiting");
|
|
89
|
+
// The hold is surfaced so gw-merge-stall re-polls past the stall cap (only for this hold).
|
|
90
|
+
assertEquals(held.updatePending, true);
|
|
91
|
+
assertEquals((await run(blocked, MERGIFY, false)).updatePending, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("merge-stall-probe: a non-blocked verdict is never downgraded by a pending update (#951)", async () => {
|
|
95
|
+
// A ready PR stays ready even if (spuriously) an update is pending — the hold only masks `blocked`.
|
|
96
|
+
const out = await run(state({}), MERGIFY, true);
|
|
97
|
+
assertEquals(out.mergeState, "ready");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// PR #953 review: a probe that emits `updatePending: true` is followed (next re-poll) by a read that
|
|
101
|
+
// does NOT recompute the hold — a failed/null state read, or a terminal (merged/closed) liveness
|
|
102
|
+
// short-circuit. Those paths must explicitly clear the flag: an omitted field would leave the stale
|
|
103
|
+
// `true` standing, and `gw-merge-stall` would keep bypassing the stall cap on it even after the
|
|
104
|
+
// remedy's bounded hold expired — a GitHub outage would turn the bounded hold into an indefinite
|
|
105
|
+
// re-poll.
|
|
106
|
+
test("merge-stall-probe: a failed/null state read clears updatePending (no stale cap bypass) (#953)", async () => {
|
|
107
|
+
const out = await runNullRead(true);
|
|
108
|
+
assertEquals(out.mergeState, "waiting");
|
|
109
|
+
assertEquals(out.updatePending, false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("merge-stall-probe: a terminal (merged/closed) read clears updatePending (#953)", async () => {
|
|
113
|
+
const merged = await run(state({ merged: true, state: "merged", mergeStateStatus: "UNKNOWN" }), MERGIFY, true);
|
|
114
|
+
assertEquals(merged.mergeState, "ready");
|
|
115
|
+
assertEquals(merged.updatePending, false);
|
|
116
|
+
const closed = await run(state({ state: "closed", mergeStateStatus: "UNKNOWN" }), null, true);
|
|
117
|
+
assertEquals(closed.mergeState, "ready");
|
|
118
|
+
assertEquals(closed.updatePending, false);
|
|
119
|
+
});
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// (incremented by this task's output mapping, capped by `mergeStallMax`) stops the probe from
|
|
18
18
|
// re-arming forever: once exhausted, `gw-merge-stall` routes to human escalation instead.
|
|
19
19
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { isBehindBaseUpdatePending } from "../../app/behindBaseRemedy.ts";
|
|
21
|
+
import { classifyMergeability, classifyPrLiveness, fetchPrState, type PrState } from "../../app/github.ts";
|
|
22
|
+
import { loadMergeProtocol, type MergeProtocol } from "../../app/mergeProtocol.ts";
|
|
22
23
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
23
24
|
|
|
24
25
|
// Input typed off the model data envelope (`MergeStallProbeIn` in merge-loop.bpmn) — ADR 0040.
|
|
@@ -43,7 +44,25 @@ interface Out extends Record<string, unknown> {
|
|
|
43
44
|
pendingChecks: number;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
/** Injectable GitHub readers (tests stub them); the default export binds the real ones. */
|
|
48
|
+
export interface StallProbeDeps {
|
|
49
|
+
readState: (repo: string, prNumber: number, token: string) => Promise<PrState | null>;
|
|
50
|
+
readProtocol: (repo: string, token: string) => Promise<MergeProtocol | null>;
|
|
51
|
+
/** Issue #951 review: is an accepted (async) update-branch still holding this head? While it is,
|
|
52
|
+
* the old head's stale check failures must NOT classify as `blocked` (which would start fix-ci /
|
|
53
|
+
* escalate) — the branch update is in flight and will move the head. Bounded by the remedy's own
|
|
54
|
+
* window, so a never-applied update stops holding. */
|
|
55
|
+
isUpdatePending: (repo: string, prNumber: number, headOid: string) => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const realDeps: StallProbeDeps = {
|
|
59
|
+
readState: (repo, prNumber, token) => fetchPrState(repo, prNumber, token, { withReviewThreads: true }),
|
|
60
|
+
readProtocol: (repo, token) => loadMergeProtocol(repo, token),
|
|
61
|
+
isUpdatePending: (repo, prNumber, headOid) => isBehindBaseUpdatePending(repo, prNumber, headOid),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function makeHandler(deps: StallProbeDeps): AppJobHandler<In, Out> {
|
|
65
|
+
return async (job, app) => {
|
|
47
66
|
const { repo, prNumber } = job.variables;
|
|
48
67
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
49
68
|
|
|
@@ -57,12 +76,50 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
57
76
|
// classified by its real cause (issue #921) — unresolved review threads surface as the
|
|
58
77
|
// `review-threads` verdict and route to their own arm instead of re-polling until the budget
|
|
59
78
|
// exhausts into the misleading "mergeability never settled" escalation.
|
|
60
|
-
const st = await
|
|
79
|
+
const st = await deps.readState(repo, prNumber, token).catch(() => null);
|
|
61
80
|
if (st === null) {
|
|
62
|
-
|
|
81
|
+
// Explicitly clear `updatePending` (PR #953 review): a failed/null read returns `waiting`
|
|
82
|
+
// WITHOUT recomputing the hold, and an omitted field would leave any earlier `true` standing —
|
|
83
|
+
// `gw-merge-stall` would then keep bypassing the stall cap on the stale flag even after the
|
|
84
|
+
// remedy's bounded hold expired. Returning `false` keeps a GitHub outage from turning the
|
|
85
|
+
// bounded hold into an indefinite re-poll.
|
|
86
|
+
return { mergeState: "waiting", updatePending: false, failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 };
|
|
87
|
+
}
|
|
88
|
+
// Issue #951: re-read PR liveness BEFORE any verdict. A PR that already MERGED (e.g. a human
|
|
89
|
+
// queued it while the loop waited) or was CLOSED must never reach an escalation: surface `ready`,
|
|
90
|
+
// which `gw-merge-stall` always routes (even past the stall budget) to `attempt-merge`, whose
|
|
91
|
+
// already-merged / closed short-circuits finish the loop as merged / abandoned.
|
|
92
|
+
const liveness = classifyPrLiveness(st);
|
|
93
|
+
if (liveness === "merged" || liveness === "closed") {
|
|
94
|
+
app.log.info("merge-stall-probe: PR already terminal — routing to attempt-merge short-circuit", {
|
|
95
|
+
prKey: job.variables.prKey,
|
|
96
|
+
liveness,
|
|
97
|
+
});
|
|
98
|
+
// `updatePending: false` here too: a terminal PR short-circuits the merge, so a stale hold
|
|
99
|
+
// from an earlier round must not survive into `gw-merge-stall`'s bypass-cap condition.
|
|
100
|
+
return { mergeState: "ready", updatePending: false, failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 };
|
|
101
|
+
}
|
|
102
|
+
const protocol = await deps.readProtocol(repo, token).catch(() => null);
|
|
103
|
+
let mergeState = classifyMergeability(st, protocol ?? undefined);
|
|
104
|
+
|
|
105
|
+
// Issue #951 review: a `blocked` verdict on the OLD head while an accepted update-branch is still
|
|
106
|
+
// pending is a stale-read, not a real block — the poller's behind-base remedy is holding that head
|
|
107
|
+
// (no fix-ci on its stale failures) until GitHub applies the update and the head moves. Mirroring
|
|
108
|
+
// that hold here, the probe downgrades the verdict to `waiting` so `gw-mergeable` routes to the
|
|
109
|
+
// bounded re-poll (`wait-mergeable-repoll`) instead of dispatching fix-ci / escalating against a
|
|
110
|
+
// head that is about to change. The hold shares the remedy's bounded window, so a never-applied
|
|
111
|
+
// update stops holding and the next probe judges the (still-red) head afresh.
|
|
112
|
+
// `updatePending` is surfaced so `gw-merge-stall` keeps re-polling inside the remedy's bounded hold
|
|
113
|
+
// window even once `mergeStallMax` is spent (an ordinary `waiting` still respects the stall cap).
|
|
114
|
+
let updatePending = false;
|
|
115
|
+
if (mergeState === "blocked" && st.headRefOid && deps.isUpdatePending(repo, prNumber, st.headRefOid)) {
|
|
116
|
+
updatePending = true;
|
|
117
|
+
app.log.info("merge-stall-probe: behind-base update pending — deferring remediation until the head moves", {
|
|
118
|
+
prKey: job.variables.prKey,
|
|
119
|
+
head: st.headRefOid,
|
|
120
|
+
});
|
|
121
|
+
mergeState = "waiting";
|
|
63
122
|
}
|
|
64
|
-
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
65
|
-
const mergeState = classifyMergeability(st, protocol ?? undefined);
|
|
66
123
|
|
|
67
124
|
app.log.info("merge-stall-probe: poller stalled — re-derived mergeability", {
|
|
68
125
|
prKey: job.variables.prKey,
|
|
@@ -72,6 +129,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
72
129
|
|
|
73
130
|
return {
|
|
74
131
|
mergeState,
|
|
132
|
+
updatePending,
|
|
75
133
|
// Carried for the CI-fix arm (mergeState "blocked" = a failed required check), mirroring the
|
|
76
134
|
// poller's `merge-ready` payload so fix-ci knows which gates to green.
|
|
77
135
|
failingChecks: st.failingChecks,
|
|
@@ -79,6 +137,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
79
137
|
reviewThreads: st.unresolvedThreads,
|
|
80
138
|
pendingChecks: st.pendingCheckNames.length,
|
|
81
139
|
};
|
|
82
|
-
};
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handler: AppJobHandler<In, Out> = makeHandler(realDeps);
|
|
83
144
|
|
|
84
145
|
export default handler;
|