@nanobpm/nano-workforce 0.39.2 → 0.40.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/CHANGELOG.md +14 -0
- package/README.md +5 -1
- package/SPEC.md +6 -1
- package/app/github.ts +21 -0
- package/app/mergeProtocol.test.ts +75 -3
- package/app/mergeProtocol.ts +75 -13
- package/app/service.test.ts +50 -0
- package/app/service.ts +14 -6
- package/openapi.yaml +5 -0
- package/operations/startAndMessage.test.ts +69 -0
- package/operations/startConvergenceLoop.ts +13 -6
- package/package.json +2 -2
- package/workers/finalize/worker.test.ts +104 -0
- package/workers/finalize/worker.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.40.0](https://github.com/nanobpm/nano-workforce/compare/v0.39.3...v0.40.0) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **convergence:** per-request convergeOnly override to skip the merge-loop ([#115](https://github.com/nanobpm/nano-workforce/issues/115)) ([8840b23](https://github.com/nanobpm/nano-workforce/commit/8840b23e97ed56aaf43df20d3f9a97b00b7e865d))
|
|
7
|
+
|
|
8
|
+
## [0.39.3](https://github.com/nanobpm/nano-workforce/compare/v0.39.2...v0.39.3) (2026-08-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **merge-loop:** judge fresh-head-run by required checks, not rollup length ([#113](https://github.com/nanobpm/nano-workforce/issues/113)) ([a10c4c5](https://github.com/nanobpm/nano-workforce/commit/a10c4c5b3ee5c452591eeb6c6206c4815674959a))
|
|
14
|
+
|
|
1
15
|
## [0.39.2](https://github.com/nanobpm/nano-workforce/compare/v0.39.1...v0.39.2) (2026-08-11)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -210,6 +210,10 @@ branch before escalating (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3). Conflicts, an
|
|
|
210
210
|
exhausted budget, or an agent that can't fix the build escalate to a human; answer in
|
|
211
211
|
the UI and the process re-arms and retries.
|
|
212
212
|
|
|
213
|
+
A single submission can pin **review-only** regardless of the global default by
|
|
214
|
+
passing `convergeOnly: true` on the `start/convergence-loop` request — the PR stops at
|
|
215
|
+
`converged` and is never handed to `merge-loop`, even with `NANO_PR_AUTO_MERGE` on.
|
|
216
|
+
|
|
213
217
|
### Fleet mode: hand it an issue (plan → implement → converge)
|
|
214
218
|
|
|
215
219
|
```
|
|
@@ -244,7 +248,7 @@ curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
|
|
|
244
248
|
| `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
|
|
245
249
|
| `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
|
|
246
250
|
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `POST /app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`); unset = open |
|
|
247
|
-
| `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only) |
|
|
251
|
+
| `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
|
|
248
252
|
| `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
|
|
249
253
|
| `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
|
|
250
254
|
| `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
|
package/SPEC.md
CHANGED
|
@@ -336,6 +336,11 @@ same `prKey`, sharing the datasource and poller. It merges the PR, honouring
|
|
|
336
336
|
merge-queue branches and cross-PR dependencies, and reuses the review stage's
|
|
337
337
|
escalation machinery for anything it can't resolve autonomously.
|
|
338
338
|
|
|
339
|
+
A per-submit `convergeOnly: true` on the `start/convergence-loop` request pins that PR
|
|
340
|
+
to review-only regardless of the global default: `pr.finalize` reads the flag off the
|
|
341
|
+
instance and rests the PR at `converged` without starting `merge-loop`. The flag only
|
|
342
|
+
ever narrows (it never forces the merge stage on when `NANO_PR_AUTO_MERGE` is off).
|
|
343
|
+
|
|
339
344
|
Flow:
|
|
340
345
|
|
|
341
346
|
```
|
|
@@ -396,7 +401,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
|
|
|
396
401
|
| `NANO_PR_POLL_MS` | 60000 | poll interval |
|
|
397
402
|
| `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
|
|
398
403
|
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `/app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`) |
|
|
399
|
-
| `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only) |
|
|
404
|
+
| `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
|
|
400
405
|
| `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
|
|
401
406
|
| `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
|
|
402
407
|
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT20M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
|
package/app/github.ts
CHANGED
|
@@ -214,6 +214,12 @@ export interface PrState {
|
|
|
214
214
|
* frugal-CI stuck state the fresh-head-run remedy targets); `-1` when the transport can't
|
|
215
215
|
* enumerate checks (token mode). */
|
|
216
216
|
totalChecks: number;
|
|
217
|
+
/** Names of every head check present in any state (pending/failed/passed). Empty in token mode
|
|
218
|
+
* (the REST fallback can't enumerate checks). Lets the fresh-head-run remedy judge whether the
|
|
219
|
+
* repo's *required* checks (per its merge protocol) are actually present on the head — an
|
|
220
|
+
* unrelated always-on check (e.g. Mergify's "Merge Queue") must not read as "the required run
|
|
221
|
+
* already happened". */
|
|
222
|
+
presentCheckNames: string[];
|
|
217
223
|
/** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
|
|
218
224
|
isDraft: boolean;
|
|
219
225
|
/** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
|
|
@@ -248,6 +254,19 @@ function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
248
254
|
return names;
|
|
249
255
|
}
|
|
250
256
|
|
|
257
|
+
/** Names of every head check present, regardless of state. Covers both the CheckRun shape
|
|
258
|
+
* (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
|
|
259
|
+
* repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
|
|
260
|
+
* Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened. */
|
|
261
|
+
function allCheckNames(rollup: RollupEntry[]): string[] {
|
|
262
|
+
const names: string[] = [];
|
|
263
|
+
for (const c of rollup) {
|
|
264
|
+
const name = c.name || c.context || c.workflowName;
|
|
265
|
+
if (name) names.push(name);
|
|
266
|
+
}
|
|
267
|
+
return names;
|
|
268
|
+
}
|
|
269
|
+
|
|
251
270
|
export async function fetchPrState(
|
|
252
271
|
repo: string,
|
|
253
272
|
number: number | string,
|
|
@@ -280,6 +299,7 @@ export async function fetchPrState(
|
|
|
280
299
|
failingChecks: names.length,
|
|
281
300
|
failingCheckNames: names,
|
|
282
301
|
totalChecks: rollup.length,
|
|
302
|
+
presentCheckNames: allCheckNames(rollup),
|
|
283
303
|
isDraft: !!j.isDraft,
|
|
284
304
|
headRefOid: j.headRefOid ?? null,
|
|
285
305
|
};
|
|
@@ -305,6 +325,7 @@ export async function fetchPrState(
|
|
|
305
325
|
failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
|
|
306
326
|
failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
|
|
307
327
|
totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
|
|
328
|
+
presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal
|
|
308
329
|
isDraft: !!j.draft,
|
|
309
330
|
headRefOid: j.head?.sha ?? null,
|
|
310
331
|
};
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
DEFAULT_MERGE_PROTOCOL,
|
|
11
11
|
extractProtocolBlock,
|
|
12
12
|
freshHeadRunAction,
|
|
13
|
+
headRunPresenceCount,
|
|
13
14
|
type MergeProtocol,
|
|
14
15
|
parseMergeProtocol,
|
|
16
|
+
presentRequiredCheckCount,
|
|
15
17
|
} from "./mergeProtocol.ts";
|
|
16
18
|
|
|
17
19
|
test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
|
|
@@ -27,7 +29,10 @@ test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
|
|
|
27
29
|
freshHeadRun: "ready-or-reopen",
|
|
28
30
|
waitForChecks: true,
|
|
29
31
|
land: { method: "mergify-queue", comment: "@mergifyio queue" },
|
|
30
|
-
requiredChecks: [
|
|
32
|
+
requiredChecks: [
|
|
33
|
+
{ name: "rustfmt (pinned nightly)", acceptedConclusions: ["success"] },
|
|
34
|
+
{ name: "processos (clippy + test)", acceptedConclusions: ["success", "skipped"] },
|
|
35
|
+
],
|
|
31
36
|
doc: "AGENTS.md#merging-prs",
|
|
32
37
|
});
|
|
33
38
|
assertEquals(got.autoMerge, false);
|
|
@@ -35,20 +40,38 @@ test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
|
|
|
35
40
|
assertEquals(got.waitForChecks, true);
|
|
36
41
|
assertEquals(got.land, { method: "mergify-queue", comment: "@mergifyio queue" });
|
|
37
42
|
assertEquals(got.requiredChecks.length, 2);
|
|
43
|
+
assertEquals(got.requiredChecks[0], { name: "rustfmt (pinned nightly)", acceptedConclusions: ["success"] });
|
|
44
|
+
assertEquals(got.requiredChecks[1].acceptedConclusions, ["success", "skipped"]);
|
|
38
45
|
assertEquals(got.doc, "AGENTS.md#merging-prs");
|
|
39
46
|
});
|
|
40
47
|
|
|
48
|
+
test("parseMergeProtocol: requiredChecks tolerates bare-string entries + drops nameless/junk", () => {
|
|
49
|
+
const got = parseMergeProtocol({
|
|
50
|
+
requiredChecks: [
|
|
51
|
+
"server (clippy + test)", // bare name → default acceptedConclusions ["success"]
|
|
52
|
+
{ name: "engine-core (clippy + test)" }, // object, no acceptedConclusions → default
|
|
53
|
+
{ name: "", acceptedConclusions: ["success"] }, // empty name → dropped
|
|
54
|
+
{ acceptedConclusions: ["success"] }, // no name → dropped
|
|
55
|
+
42, // junk → dropped
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
assertEquals(got.requiredChecks, [
|
|
59
|
+
{ name: "server (clippy + test)", acceptedConclusions: ["success"] },
|
|
60
|
+
{ name: "engine-core (clippy + test)", acceptedConclusions: ["success"] },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
41
64
|
test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
|
|
42
65
|
const got = parseMergeProtocol({
|
|
43
66
|
autoMerge: "yes", // not a boolean → default
|
|
44
67
|
freshHeadRun: "sometimes", // not in the enum → default (none)
|
|
45
68
|
land: { method: "teleport" }, // not in the enum → default (gh-merge)
|
|
46
|
-
requiredChecks: ["ok", 7, null], // keep only
|
|
69
|
+
requiredChecks: ["ok", 7, null], // keep only usable names
|
|
47
70
|
});
|
|
48
71
|
assertEquals(got.autoMerge, DEFAULT_MERGE_PROTOCOL.autoMerge);
|
|
49
72
|
assertEquals(got.freshHeadRun, "none");
|
|
50
73
|
assertEquals(got.land.method, "gh-merge");
|
|
51
|
-
assertEquals(got.requiredChecks, ["ok"]);
|
|
74
|
+
assertEquals(got.requiredChecks, [{ name: "ok", acceptedConclusions: ["success"] }]);
|
|
52
75
|
});
|
|
53
76
|
|
|
54
77
|
test("parseMergeProtocol: comment dropped when absent", () => {
|
|
@@ -123,3 +146,52 @@ test("freshHeadRunAction: mode=ready only acts on drafts", () => {
|
|
|
123
146
|
assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, true), "ready");
|
|
124
147
|
assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, false), null); // not a draft → nothing to ready
|
|
125
148
|
});
|
|
149
|
+
|
|
150
|
+
// The nano-bpm merge protocol: 3 required checks, one skip-tolerant.
|
|
151
|
+
const NANO_REQ: MergeProtocol = parseMergeProtocol({
|
|
152
|
+
freshHeadRun: "ready-or-reopen",
|
|
153
|
+
land: { method: "mergify-queue" },
|
|
154
|
+
requiredChecks: [
|
|
155
|
+
{ name: "rustfmt (pinned nightly)", acceptedConclusions: ["success"] },
|
|
156
|
+
{ name: "server (clippy + test)", acceptedConclusions: ["success"] },
|
|
157
|
+
{ name: "processos (clippy + test)", acceptedConclusions: ["success", "skipped"] },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("presentRequiredCheckCount: counts only declared required checks present on the head", () => {
|
|
162
|
+
// Only an unrelated always-on check (Mergify) is present → zero required checks present.
|
|
163
|
+
assertEquals(presentRequiredCheckCount(NANO_REQ, ["Mergify Merge Queue"]), 0);
|
|
164
|
+
// Two of the three required checks present (plus the incidental Mergify one).
|
|
165
|
+
assertEquals(
|
|
166
|
+
presentRequiredCheckCount(NANO_REQ, ["Mergify Merge Queue", "server (clippy + test)", "rustfmt (pinned nightly)"]),
|
|
167
|
+
2,
|
|
168
|
+
);
|
|
169
|
+
// A repo that declares no required checks → nothing to count.
|
|
170
|
+
assertEquals(presentRequiredCheckCount(DEFAULT_MERGE_PROTOCOL, ["anything"]), 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("headRunPresenceCount: required-aware — Mergify's incidental check doesn't mask a missing run", () => {
|
|
174
|
+
// The #727 stuck state: BLOCKED head carries only Mergify's neutral check, none of the 3
|
|
175
|
+
// required checks ran. Raw rollup length is 1, but the required-check presence is 0 → the
|
|
176
|
+
// remedy must see 0 and fire the reopen.
|
|
177
|
+
const st = { totalChecks: 1, presentCheckNames: ["Mergify Merge Queue"] };
|
|
178
|
+
assertEquals(headRunPresenceCount(NANO_REQ, st), 0);
|
|
179
|
+
assertEquals(freshHeadRunAction(NANO_REQ, "waiting", headRunPresenceCount(NANO_REQ, st), false), "reopen");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("headRunPresenceCount: a present required check reads as run-exists (no reopen)", () => {
|
|
183
|
+
const st = { totalChecks: 2, presentCheckNames: ["Mergify Merge Queue", "server (clippy + test)"] };
|
|
184
|
+
assertEquals(headRunPresenceCount(NANO_REQ, st), 1);
|
|
185
|
+
assertEquals(freshHeadRunAction(NANO_REQ, "waiting", headRunPresenceCount(NANO_REQ, st), false), null);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("headRunPresenceCount: no declared required checks → falls back to total rollup length", () => {
|
|
189
|
+
const proto = parseMergeProtocol({ freshHeadRun: "ready-or-reopen", land: { method: "gh-merge" } });
|
|
190
|
+
assertEquals(headRunPresenceCount(proto, { totalChecks: 0, presentCheckNames: [] }), 0);
|
|
191
|
+
assertEquals(headRunPresenceCount(proto, { totalChecks: 3, presentCheckNames: ["a", "b", "c"] }), 3);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("headRunPresenceCount: token mode (totalChecks < 0) stays conservative (-1)", () => {
|
|
195
|
+
assertEquals(headRunPresenceCount(NANO_REQ, { totalChecks: -1, presentCheckNames: [] }), -1);
|
|
196
|
+
assertEquals(freshHeadRunAction(NANO_REQ, "waiting", -1, false), null);
|
|
197
|
+
});
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -28,6 +28,16 @@ export type FreshHeadRun = "none" | "ready" | "reopen" | "ready-or-reopen";
|
|
|
28
28
|
* `ui` = a human clicks Merge (Merlin can't do it → escalate). */
|
|
29
29
|
export type LandMethod = "gh-merge" | "admin" | "mergify-queue" | "ui";
|
|
30
30
|
|
|
31
|
+
/** One required status check a repo declares in its merge protocol. `name` is the check-run /
|
|
32
|
+
* status-context name exactly as GitHub reports it in the head `statusCheckRollup`.
|
|
33
|
+
* `acceptedConclusions` are the conclusions that count as satisfied (default `["success"]`); a
|
|
34
|
+
* change-gated check that is skipped for irrelevant PRs also lists `"skipped"` so a skip counts
|
|
35
|
+
* as satisfied (required-when-run, skip-tolerant). */
|
|
36
|
+
export interface RequiredCheck {
|
|
37
|
+
name: string;
|
|
38
|
+
acceptedConclusions: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
export interface MergeProtocol {
|
|
32
42
|
/** Does the repo auto-merge a PR once its checks go green? (Informational; Merlin never relies
|
|
33
43
|
* on auto-merge — it lands deliberately.) */
|
|
@@ -38,8 +48,10 @@ export interface MergeProtocol {
|
|
|
38
48
|
waitForChecks: boolean;
|
|
39
49
|
/** How to land the PR. */
|
|
40
50
|
land: { method: LandMethod; comment?: string };
|
|
41
|
-
/**
|
|
42
|
-
|
|
51
|
+
/** The checks that gate the merge. A repo publishing these lets the fresh-head-run remedy judge
|
|
52
|
+
* "is the required CI run present on the head?" by *these* checks — not by total rollup length,
|
|
53
|
+
* which an unrelated always-on check (e.g. Mergify's "Merge Queue") would otherwise satisfy. */
|
|
54
|
+
requiredChecks: RequiredCheck[];
|
|
43
55
|
/** Pointer to the human doc, for escalation messages. */
|
|
44
56
|
doc?: string;
|
|
45
57
|
}
|
|
@@ -70,6 +82,30 @@ function strArray(v: unknown): string[] | undefined {
|
|
|
70
82
|
if (!Array.isArray(v)) return undefined;
|
|
71
83
|
return v.filter((x): x is string => typeof x === "string");
|
|
72
84
|
}
|
|
85
|
+
/** Parse `requiredChecks`, tolerating both the rich object shape (`{ name, acceptedConclusions }`)
|
|
86
|
+
* and a bare list of check names (each → `{ name, acceptedConclusions: ["success"] }`). Entries
|
|
87
|
+
* without a usable `name` are dropped. Total — never throws. */
|
|
88
|
+
function requiredCheckArray(v: unknown): RequiredCheck[] {
|
|
89
|
+
if (!Array.isArray(v)) return [];
|
|
90
|
+
const out: RequiredCheck[] = [];
|
|
91
|
+
for (const entry of v) {
|
|
92
|
+
if (typeof entry === "string") {
|
|
93
|
+
const name = entry.trim();
|
|
94
|
+
if (name === "") continue;
|
|
95
|
+
out.push({ name, acceptedConclusions: ["success"] });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!isRecord(entry)) continue;
|
|
99
|
+
const name = str(entry.name)?.trim();
|
|
100
|
+
if (name === undefined || name === "") continue;
|
|
101
|
+
const accepted = strArray(entry.acceptedConclusions);
|
|
102
|
+
out.push({
|
|
103
|
+
name,
|
|
104
|
+
acceptedConclusions: accepted && accepted.length > 0 ? accepted : ["success"],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
73
109
|
function oneOf<T extends string>(v: unknown, allowed: ReadonlySet<string>): T | undefined {
|
|
74
110
|
const s = str(v);
|
|
75
111
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
@@ -88,7 +124,7 @@ export function parseMergeProtocol(raw: unknown): MergeProtocol {
|
|
|
88
124
|
freshHeadRun: oneOf<FreshHeadRun>(raw.freshHeadRun, FRESH_HEAD_RUNS) ?? DEFAULT_MERGE_PROTOCOL.freshHeadRun,
|
|
89
125
|
waitForChecks: bool(raw.waitForChecks) ?? DEFAULT_MERGE_PROTOCOL.waitForChecks,
|
|
90
126
|
land: comment !== undefined ? { method, comment } : { method },
|
|
91
|
-
requiredChecks:
|
|
127
|
+
requiredChecks: requiredCheckArray(raw.requiredChecks),
|
|
92
128
|
doc: str(raw.doc),
|
|
93
129
|
};
|
|
94
130
|
}
|
|
@@ -159,27 +195,53 @@ export interface FreshHeadRunAttempt {
|
|
|
159
195
|
lastActionHeadRefOid?: string | null;
|
|
160
196
|
}
|
|
161
197
|
|
|
198
|
+
/** Count of the protocol's required checks currently present on the head (in any state). This is
|
|
199
|
+
* the signal the fresh-head-run remedy actually wants — "has the required CI run happened?" — as
|
|
200
|
+
* opposed to the raw rollup length, which an unrelated always-on check (e.g. Mergify's "Merge
|
|
201
|
+
* Queue") inflates. A required check matches by exact name against the head `statusCheckRollup`. */
|
|
202
|
+
export function presentRequiredCheckCount(protocol: MergeProtocol, presentCheckNames: string[]): number {
|
|
203
|
+
const present = new Set(presentCheckNames);
|
|
204
|
+
return protocol.requiredChecks.filter((c) => present.has(c.name)).length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The "does a head run already exist?" count to feed {@link freshHeadRunAction}. When the repo
|
|
208
|
+
* declares `requiredChecks`, judge presence by *those* checks — so an incidental always-on check
|
|
209
|
+
* (Mergify) never masks a genuinely-missing required run and wedges the merge. When it declares
|
|
210
|
+
* none, fall back to the total rollup length (legacy behaviour, default repos unchanged). Token
|
|
211
|
+
* mode (`totalChecks < 0`, checks unenumerable) stays `-1` so the remedy remains conservative and
|
|
212
|
+
* never reopens blind. */
|
|
213
|
+
export function headRunPresenceCount(
|
|
214
|
+
protocol: MergeProtocol,
|
|
215
|
+
state: { totalChecks: number; presentCheckNames: string[] },
|
|
216
|
+
): number {
|
|
217
|
+
if (state.totalChecks < 0) return -1; // token mode → unknown → conservative
|
|
218
|
+
if (protocol.requiredChecks.length === 0) return state.totalChecks;
|
|
219
|
+
return presentRequiredCheckCount(protocol, state.presentCheckNames);
|
|
220
|
+
}
|
|
221
|
+
|
|
162
222
|
/** Whether the merge poller should produce a synthetic fresh head run *now*, and how.
|
|
163
223
|
*
|
|
164
|
-
* Fires only when the protocol asks for a fresh run AND the PR currently has **no head
|
|
165
|
-
*
|
|
224
|
+
* Fires only when the protocol asks for a fresh run AND the PR currently has **no required head
|
|
225
|
+
* run** (`headRunCount === 0`) while GitHub still reports it un-landable-but-not-conflicting
|
|
166
226
|
* (`waiting`). That is exactly the frugal-CI stuck state: review converged, the last push produced
|
|
167
|
-
* no run, so branch protection's required checks read as *expected* forever.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* rebase
|
|
227
|
+
* no run, so branch protection's required checks read as *expected* forever. `headRunCount` is the
|
|
228
|
+
* required-check-aware presence count from {@link headRunPresenceCount} — NOT the raw rollup
|
|
229
|
+
* length — so an incidental always-on check (e.g. Mergify's "Merge Queue") does not read as "a run
|
|
230
|
+
* already exists". Once the required run is present (`headRunCount > 0`, pending or done), or this
|
|
231
|
+
* same head already got its nudge, this returns `null`, so the poller never re-triggers inside one
|
|
232
|
+
* landing attempt. A rebase changes `headRefOid`, so the decision is re-derived and can fire again
|
|
233
|
+
* for the fresh post-rebase head. A genuinely-failing check (`blocked`) is left to the fix-ci arm,
|
|
234
|
+
* a conflict (`conflict`) to the rebase arm (#42). */
|
|
173
235
|
export function freshHeadRunAction(
|
|
174
236
|
protocol: MergeProtocol,
|
|
175
237
|
verdict: "ready" | "waiting" | "conflict" | "blocked",
|
|
176
|
-
|
|
238
|
+
headRunCount: number,
|
|
177
239
|
isDraft: boolean,
|
|
178
240
|
attempt: FreshHeadRunAttempt = {},
|
|
179
241
|
): "ready" | "reopen" | null {
|
|
180
242
|
if (protocol.freshHeadRun === "none") return null;
|
|
181
243
|
if (verdict !== "waiting") return null; // ready = go land; blocked/conflict = other arms
|
|
182
|
-
if (
|
|
244
|
+
if (headRunCount !== 0) return null; // required run already present (or unknown in token mode) → wait
|
|
183
245
|
if (attempt.headRefOid && attempt.headRefOid === attempt.lastActionHeadRefOid) return null;
|
|
184
246
|
switch (protocol.freshHeadRun) {
|
|
185
247
|
case "ready":
|
package/app/service.test.ts
CHANGED
|
@@ -281,3 +281,53 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
281
281
|
assertEquals(pr.process_key, "2251799813685249");
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
// Per-request review-only override: `submitPr` carries `convergeOnly` onto the convergence
|
|
286
|
+
// instance so `pr.finalize` can stop at `converged` without handing off to the merge-loop. Default
|
|
287
|
+
// false (so the global auto-merge default governs); true when the caller pins review-only.
|
|
288
|
+
function captureConvergeOnly() {
|
|
289
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
290
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
291
|
+
escalations: { rows: [], key: "id" },
|
|
292
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
293
|
+
};
|
|
294
|
+
const data = {
|
|
295
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
296
|
+
} as any;
|
|
297
|
+
let captured: unknown;
|
|
298
|
+
const engine = {
|
|
299
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
300
|
+
captured = req.variables?.convergeOnly;
|
|
301
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
302
|
+
},
|
|
303
|
+
} as any;
|
|
304
|
+
return { data, engine, get: () => captured };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
test("submitPr threads convergeOnly=true onto the instance as a process variable", async () => {
|
|
308
|
+
await withGithubOff(async () => {
|
|
309
|
+
const { data, engine, get } = captureConvergeOnly();
|
|
310
|
+
await submitPr(
|
|
311
|
+
data,
|
|
312
|
+
engine,
|
|
313
|
+
{ repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
|
|
314
|
+
[],
|
|
315
|
+
20,
|
|
316
|
+
true,
|
|
317
|
+
);
|
|
318
|
+
assertEquals(get(), true);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("submitPr defaults convergeOnly to false so the global auto-merge default governs", async () => {
|
|
323
|
+
await withGithubOff(async () => {
|
|
324
|
+
const { data, engine, get } = captureConvergeOnly();
|
|
325
|
+
await submitPr(data, engine, {
|
|
326
|
+
repo: "owner/repo",
|
|
327
|
+
number: 9,
|
|
328
|
+
url: "https://github.com/owner/repo/pull/9",
|
|
329
|
+
prKey: "owner/repo#9",
|
|
330
|
+
});
|
|
331
|
+
assertEquals(get(), false);
|
|
332
|
+
});
|
|
333
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
requestCopilotReview,
|
|
21
21
|
} from "./github.ts";
|
|
22
22
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
23
|
-
import { freshHeadRunAction, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
23
|
+
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
24
24
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
25
25
|
import { plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
26
26
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
@@ -285,6 +285,7 @@ export async function submitPr(
|
|
|
285
285
|
parsed: ParsedPr,
|
|
286
286
|
dependsOn: string[] = [],
|
|
287
287
|
maxRounds: number = MAX_ROUNDS,
|
|
288
|
+
convergeOnly = false,
|
|
288
289
|
) {
|
|
289
290
|
const table = prs(data);
|
|
290
291
|
const existing = await table.get(parsed.prKey);
|
|
@@ -365,6 +366,10 @@ export async function submitPr(
|
|
|
365
366
|
round: 1,
|
|
366
367
|
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
367
368
|
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
369
|
+
// Per-request review-only override: carried on the instance so `pr.finalize` can stop at
|
|
370
|
+
// `converged` for this PR without handing off to the merge-loop, independent of the global
|
|
371
|
+
// NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
|
|
372
|
+
convergeOnly,
|
|
368
373
|
// Cooperative abandon check (#76): the capability URL + the abort brief appended to the
|
|
369
374
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
370
375
|
abandonUrl: abUrl,
|
|
@@ -686,14 +691,17 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
|
|
|
686
691
|
const verdict = classifyMergeability(st);
|
|
687
692
|
if (verdict === "waiting") {
|
|
688
693
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
689
|
-
// head run and the PR has NO head run
|
|
694
|
+
// head run and the PR has NO required head run yet, review has converged but the last push
|
|
690
695
|
// produced no CI run — so branch protection's required checks read as "expected" forever
|
|
691
|
-
// and this PR would wait indefinitely.
|
|
692
|
-
// (
|
|
693
|
-
//
|
|
696
|
+
// and this PR would wait indefinitely. Judge "no run yet" by the protocol's *required*
|
|
697
|
+
// checks (headRunPresenceCount), not the raw rollup length, so an incidental always-on
|
|
698
|
+
// check (e.g. Mergify's "Merge Queue") doesn't mask a missing run. Produce a fresh
|
|
699
|
+
// `pull_request` run once per head (mark ready / close+reopen); rebases change
|
|
700
|
+
// `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
|
|
701
|
+
// landing attempt.
|
|
694
702
|
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
695
703
|
if (protocol) {
|
|
696
|
-
const action = freshHeadRunAction(protocol, verdict, st
|
|
704
|
+
const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
|
|
697
705
|
headRefOid: st.headRefOid,
|
|
698
706
|
lastActionHeadRefOid: pr.fresh_head_run_head,
|
|
699
707
|
});
|
package/openapi.yaml
CHANGED
|
@@ -189,6 +189,11 @@ components:
|
|
|
189
189
|
type: integer
|
|
190
190
|
minimum: 1
|
|
191
191
|
description: Values above 100 are accepted and clamped to 100 by the delegate.
|
|
192
|
+
convergeOnly:
|
|
193
|
+
type: boolean
|
|
194
|
+
description: When true, run convergence only and stop at `converged` — the PR is never
|
|
195
|
+
handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
|
|
196
|
+
per-request review-only override; defaults to false (the global auto-merge default applies).
|
|
192
197
|
MessageResult:
|
|
193
198
|
type: object
|
|
194
199
|
description: The result of publishing a message / answering an escalation. Shape varies by message
|
|
@@ -29,6 +29,75 @@ test("startConvergenceLoop → 400 on an unparseable PR reference", async () =>
|
|
|
29
29
|
assertEquals(typeof r.body.error, "string");
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// The delegate forwards a per-request review-only override to `submitPr`, coercing strictly: only a
|
|
33
|
+
// JSON `true` enables convergence-only (a stray string/other value is NOT truthy-coerced). Drives the
|
|
34
|
+
// real delegate → submitPr against an in-memory app and captures the `convergeOnly` process variable.
|
|
35
|
+
function captureApp() {
|
|
36
|
+
const rows: Record<string, unknown>[] = [];
|
|
37
|
+
let captured: unknown;
|
|
38
|
+
const data = {
|
|
39
|
+
table: (_name: string, key: string) => ({
|
|
40
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
41
|
+
find: (q: Record<string, unknown>) =>
|
|
42
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
43
|
+
insert: (r: Record<string, unknown>) => {
|
|
44
|
+
rows.push(r);
|
|
45
|
+
return Promise.resolve(r);
|
|
46
|
+
},
|
|
47
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
48
|
+
const row = rows.find((r) => r[key] === k);
|
|
49
|
+
if (row) Object.assign(row, patch);
|
|
50
|
+
return Promise.resolve(row);
|
|
51
|
+
},
|
|
52
|
+
delete: (k: unknown) => {
|
|
53
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
54
|
+
if (i >= 0) rows.splice(i, 1);
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
const engine = {
|
|
60
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
61
|
+
captured = req.variables?.convergeOnly;
|
|
62
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return { app: { data, engine } as any as AppApi, get: () => captured };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
69
|
+
const prev = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
70
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
71
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token → meta fetch is skipped
|
|
72
|
+
delete process.env["GITHUB_TOKEN"];
|
|
73
|
+
return run().finally(() => {
|
|
74
|
+
if (prev !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prev;
|
|
75
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
76
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("startConvergenceLoop forwards convergeOnly:true to the loop", async () => {
|
|
81
|
+
await withGithubOff(async () => {
|
|
82
|
+
const { app: capApp, get } = captureApp();
|
|
83
|
+
const res = await startConvergenceLoop(input({ pr: "owner/repo#8", convergeOnly: true }), capApp);
|
|
84
|
+
assertEquals((res as any).status, 202);
|
|
85
|
+
assertEquals(get(), true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("startConvergenceLoop defaults convergeOnly to false and does not truthy-coerce a non-boolean", async () => {
|
|
90
|
+
await withGithubOff(async () => {
|
|
91
|
+
const omitted = captureApp();
|
|
92
|
+
await startConvergenceLoop(input({ pr: "owner/repo#9" }), omitted.app);
|
|
93
|
+
assertEquals(omitted.get(), false);
|
|
94
|
+
|
|
95
|
+
const stringy = captureApp();
|
|
96
|
+
await startConvergenceLoop(input({ pr: "owner/repo#10", convergeOnly: "true" }), stringy.app);
|
|
97
|
+
assertEquals(stringy.get(), false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
32
101
|
test("startPlanFanout → 400 on an unparseable issue reference", async () => {
|
|
33
102
|
const res = await startPlanFanout(input({ issue: "" }), app);
|
|
34
103
|
const r = res as any;
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
|
|
4
4
|
// register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
|
|
5
5
|
//
|
|
6
|
-
// The request body is FLAT (`{ pr | url, dependsOn?, maxRounds? }`), not wrapped in a
|
|
7
|
-
// envelope: this is a purpose-built operation, not a generic engine "start process" call,
|
|
8
|
-
// not leak the engine's variable-map concept to callers. The runtime validates the body
|
|
9
|
-
// openapi.yaml; this delegate keeps the PR-parse guard because the reference format
|
|
10
|
-
// or a URL) is app logic, not something the JSON schema can express — an unparseable
|
|
6
|
+
// The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
|
|
7
|
+
// `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
|
|
8
|
+
// so it does not leak the engine's variable-map concept to callers. The runtime validates the body
|
|
9
|
+
// against openapi.yaml; this delegate keeps the PR-parse guard because the reference format
|
|
10
|
+
// (owner/repo#123 or a URL) is app logic, not something the JSON schema can express — an unparseable
|
|
11
|
+
// reference is a 400.
|
|
11
12
|
|
|
12
13
|
import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
|
|
13
14
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -21,5 +22,11 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
|
|
|
21
22
|
}
|
|
22
23
|
const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
|
|
23
24
|
const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
|
|
24
|
-
|
|
25
|
+
// Per-request review-only override: when true the PR stops at `converged` and is never
|
|
26
|
+
// handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
|
|
27
|
+
const convergeOnly = b.convergeOnly === true;
|
|
28
|
+
return {
|
|
29
|
+
status: 202,
|
|
30
|
+
body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly),
|
|
31
|
+
};
|
|
25
32
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"lint:fix": "biome check --write app operations workers pages components scripts main.ts"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@nanobpm/urban": "^0.
|
|
48
|
+
"@nanobpm/urban": "^0.40.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@biomejs/biome": "^2.4.11",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Red/green for the per-request review-only override (`convergeOnly`). `pr.finalize` decides,
|
|
2
|
+
// on convergence, whether to hand the PR to the merge-loop (auto-merge) or rest it at `converged`
|
|
3
|
+
// (review-only). The global default is `NANO_PR_AUTO_MERGE` (on), but a single submission can pin
|
|
4
|
+
// review-only by carrying `convergeOnly: true` on the instance. These drive the worker against an
|
|
5
|
+
// in-memory data layer + a capturing engine and assert the hand-off happens iff auto-merge is on
|
|
6
|
+
// AND the request did not force convergence-only.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
9
|
+
import handler from "./worker.ts";
|
|
10
|
+
import { MERGE_PROCESS_ID } from "../../app/service.ts";
|
|
11
|
+
|
|
12
|
+
function fakeApp() {
|
|
13
|
+
const stores: Record<string, Record<string, unknown>[]> = {
|
|
14
|
+
pull_requests: [],
|
|
15
|
+
rounds: [],
|
|
16
|
+
};
|
|
17
|
+
const createdProcesses: string[] = [];
|
|
18
|
+
return {
|
|
19
|
+
createdProcesses,
|
|
20
|
+
stores,
|
|
21
|
+
app: {
|
|
22
|
+
data: {
|
|
23
|
+
table(name: string, key: string) {
|
|
24
|
+
const store = (stores[name] ??= []);
|
|
25
|
+
return {
|
|
26
|
+
// biome-ignore lint/plugin: in-memory test double for the data layer
|
|
27
|
+
get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
28
|
+
// biome-ignore lint/plugin: in-memory test double for the data layer
|
|
29
|
+
find: (q: Record<string, unknown>) =>
|
|
30
|
+
Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
31
|
+
// biome-ignore lint/plugin: in-memory test double for the data layer
|
|
32
|
+
insert: (row: Record<string, unknown>) => {
|
|
33
|
+
store.push(row);
|
|
34
|
+
return Promise.resolve(store.length);
|
|
35
|
+
},
|
|
36
|
+
// biome-ignore lint/plugin: in-memory test double for the data layer
|
|
37
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
38
|
+
const row = store.find((r) => r[key] === k);
|
|
39
|
+
if (row) Object.assign(row, patch);
|
|
40
|
+
return Promise.resolve(row);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
engine: {
|
|
46
|
+
createInstance: (req: { processDefinitionId: string }) => {
|
|
47
|
+
createdProcesses.push(req.processDefinitionId);
|
|
48
|
+
return Promise.resolve({ processInstanceKey: "MERGE-1" });
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
log: () => undefined,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const BASE_VARS = {
|
|
57
|
+
prKey: "owner/repo#5",
|
|
58
|
+
repo: "owner/repo",
|
|
59
|
+
prNumber: 5,
|
|
60
|
+
prUrl: "https://github.com/owner/repo/pull/5",
|
|
61
|
+
round: 2,
|
|
62
|
+
summary: "looks good",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Auto-retro reads plan tables this fixture doesn't populate; disable it so the terminal
|
|
66
|
+
// `converged` path doesn't spuriously probe for a retro. The hand-off decision under test is
|
|
67
|
+
// independent of retro. Auto-merge is left at its default (on): `AUTO_MERGE` is computed once at
|
|
68
|
+
// `app/service.ts` import time and captured by the imported handler, so toggling
|
|
69
|
+
// `NANO_PR_AUTO_MERGE` here would be a no-op — only `NANO_AUTO_RETRO` is read dynamically.
|
|
70
|
+
function withRetroOff(run: () => Promise<void>): Promise<void> {
|
|
71
|
+
const prevRetro = process.env["NANO_AUTO_RETRO"];
|
|
72
|
+
process.env["NANO_AUTO_RETRO"] = "0";
|
|
73
|
+
return run().finally(() => {
|
|
74
|
+
if (prevRetro == null) delete process.env["NANO_AUTO_RETRO"];
|
|
75
|
+
else process.env["NANO_AUTO_RETRO"] = prevRetro;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("finalize with convergeOnly=true rests the PR at converged and never starts the merge-loop", async () => {
|
|
80
|
+
await withRetroOff(async () => {
|
|
81
|
+
const { app, stores, createdProcesses } = fakeApp();
|
|
82
|
+
// biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
|
|
83
|
+
await handler({ variables: { ...BASE_VARS, convergeOnly: true } } as never, app as never);
|
|
84
|
+
|
|
85
|
+
// No merge-loop instance started even though auto-merge is on globally …
|
|
86
|
+
assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), false);
|
|
87
|
+
// … and the PR rests at the review-only terminal status.
|
|
88
|
+
const pr = stores.pull_requests[0];
|
|
89
|
+
assertEquals(pr.status, "converged");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("finalize with convergeOnly absent hands off to the merge-loop when auto-merge is on", async () => {
|
|
94
|
+
await withRetroOff(async () => {
|
|
95
|
+
const { app, stores, createdProcesses } = fakeApp();
|
|
96
|
+
// biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
|
|
97
|
+
await handler({ variables: { ...BASE_VARS } } as never, app as never);
|
|
98
|
+
|
|
99
|
+
// The default (env-governed) path starts the merge-loop and parks the PR in the merge stage.
|
|
100
|
+
assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), true);
|
|
101
|
+
const pr = stores.pull_requests[0];
|
|
102
|
+
assertEquals(pr.status, "waiting_deps");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -15,6 +15,9 @@ interface In extends Record<string, unknown> {
|
|
|
15
15
|
prUrl: string;
|
|
16
16
|
round: number;
|
|
17
17
|
summary?: string;
|
|
18
|
+
// Per-request review-only override: when true, stop at `converged` and never hand off to the
|
|
19
|
+
// merge-loop even if auto-merge is on globally. Set at submit time, carried on the instance.
|
|
20
|
+
convergeOnly?: boolean;
|
|
18
21
|
// The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
|
|
19
22
|
// the agent's cooperative-abort check keeps resolving (see ensurePr).
|
|
20
23
|
abandonUrl?: string;
|
|
@@ -31,7 +34,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
31
34
|
// `summary` is left undefined when absent so the write boundary omits it: the
|
|
32
35
|
// nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
|
|
33
36
|
// rather than being coerced to "".
|
|
34
|
-
const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl } = job.variables;
|
|
37
|
+
const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl, convergeOnly } = job.variables;
|
|
35
38
|
const now = new Date().toISOString();
|
|
36
39
|
|
|
37
40
|
// Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert.
|
|
@@ -60,8 +63,11 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
60
63
|
// once merge-loop is actually running — otherwise the PR would be parked in a merge-stage status
|
|
61
64
|
// with no process behind it, and `submitPr` refuses to restart it (only `cancel` recovers). On
|
|
62
65
|
// failure we leave the PR terminal as `converged` so a human/operator can (re)start merge.
|
|
66
|
+
//
|
|
67
|
+
// A per-request `convergeOnly` override forces the review-only path for this PR regardless of the
|
|
68
|
+
// global auto-merge default — the PR rests at `converged` and is never handed to the merge-loop.
|
|
63
69
|
let status = "converged";
|
|
64
|
-
if (AUTO_MERGE) {
|
|
70
|
+
if (AUTO_MERGE && convergeOnly !== true) {
|
|
65
71
|
try {
|
|
66
72
|
const { mergeProcessKey } = await startMerge(app.data, app.engine, {
|
|
67
73
|
repo,
|