@nanobpm/nano-workforce 0.204.1 → 0.205.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/SPEC.md +10 -7
- package/app/agentOutputEnvelope.test.ts +50 -0
- package/app/feature.ts +3 -3
- package/app/featureDelivery.test.ts +2 -0
- package/app/github.test.ts +285 -1
- package/app/github.ts +160 -8
- package/app/mergeApproval.test.ts +20 -1
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +129 -0
- package/app/mergeStatus.test.ts +169 -0
- package/app/readiness.ts +1 -0
- package/app/service.test.ts +25 -0
- package/app/service.ts +257 -10
- package/app/userTasks.ts +1 -0
- package/docs/agent-guide.md +33 -0
- package/openapi.yaml +34 -0
- package/operations/getAgentInstructions.test.ts +16 -1
- package/operations/listActivePrs.ts +1 -1
- package/operations/startAndMessage.test.ts +13 -3
- package/operations/startConvergenceLoop.ts +10 -4
- package/operations/startFeature.readiness.integration.test.ts +15 -0
- package/operations/startFeature.ts +6 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +8 -0
- package/resources/processes/merge-loop.bpmn +55 -9
- package/skills/nano-workforce/SKILL.md +7 -0
- package/workers/converge-feature/worker.ts +8 -6
- package/workers/delivery-connector/worker.ts +1 -1
- package/workers/finalize/worker.test.ts +35 -0
- package/workers/finalize/worker.ts +9 -4
- package/workers/merge-stall-probe/worker.ts +17 -3
|
@@ -96,6 +96,8 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
96
96
|
mergeStatus: null,
|
|
97
97
|
ciBlockedReconciled: null,
|
|
98
98
|
failingChecks: 0,
|
|
99
|
+
reviewThreads: null,
|
|
100
|
+
pendingChecks: null,
|
|
99
101
|
round: 2,
|
|
100
102
|
abandonUrl: "http://host/app/api/hooks/abandon?token=tok-seeded",
|
|
101
103
|
};
|
|
@@ -521,6 +523,133 @@ test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#
|
|
|
521
523
|
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "the default arm must not page a human");
|
|
522
524
|
});
|
|
523
525
|
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// BLOCKED by unresolved review threads (issue #921)
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// A PR at MERGEABLE/BLOCKED with all checks green and ≥1 unresolved review thread (branch
|
|
530
|
+
// protection `required_conversation_resolution`) used to classify as "waiting": the poller never
|
|
531
|
+
// published, the mergeable-wait timer fired, the stall probe re-derived the same "waiting", and
|
|
532
|
+
// after the stall budget the escalation read "GitHub never settled this PR's mergeability" — when
|
|
533
|
+
// GitHub HAD settled. The `review-threads` verdict now routes to a dedicated escalation that names
|
|
534
|
+
// the real cause (and parks the shared `wait-merge-answer` task, whose answer re-arms the poller),
|
|
535
|
+
// never to the CI-fix or rebase agents.
|
|
536
|
+
|
|
537
|
+
test("a 'review-threads' verdict escalates naming the unresolved threads — never 'never settled', never an agent (#921)", async () => {
|
|
538
|
+
const engine = await boot();
|
|
539
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
540
|
+
await engine.publishMessage({
|
|
541
|
+
name: "merge-ready",
|
|
542
|
+
correlationKey: "pr-1",
|
|
543
|
+
variables: { mergeState: "review-threads", reviewThreads: 2, failingChecks: 0, failingChecksList: "" },
|
|
544
|
+
});
|
|
545
|
+
// The threads arm parks the shared answer task with the thread-specific question…
|
|
546
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
547
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasCompletedElements("merge-esc-threads");
|
|
548
|
+
const q = String(escalation(engine).question ?? "");
|
|
549
|
+
assertStringIncludes(q, "2 unresolved review thread(s)", "the escalation must name the actual blocker and count");
|
|
550
|
+
assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
|
|
551
|
+
// …and never misroutes to the remediation agents or the generic conflict escalation.
|
|
552
|
+
assert(!completedElementIds(engine).has("fix-ci"), "green checks must not dispatch fix-ci");
|
|
553
|
+
assert(!completedElementIds(engine).has("rebase"), "a threads block must not dispatch rebase");
|
|
554
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "the threads arm has its own escalation");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("answering the review-threads escalation re-arms the merge poller (#921)", async () => {
|
|
558
|
+
// The human resolves the threads on GitHub and replies; the answer re-arms `arm-merge` so the
|
|
559
|
+
// next poll re-derives mergeability — once the threads are gone the verdict flips to `ready` and
|
|
560
|
+
// the merge proceeds (the merge step is thereby gated on zero unresolved threads).
|
|
561
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "merged" } } });
|
|
562
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
563
|
+
await engine.publishMessage({
|
|
564
|
+
name: "merge-ready",
|
|
565
|
+
correlationKey: "pr-1",
|
|
566
|
+
variables: { mergeState: "review-threads", reviewThreads: 1, failingChecks: 0 },
|
|
567
|
+
});
|
|
568
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
569
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "resolved the threads" });
|
|
570
|
+
// Re-armed at the poller wait — not merged yet (the poller must re-derive)…
|
|
571
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
572
|
+
// …then the re-derived ready verdict lands the merge with no human in the loop.
|
|
573
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready", reviewThreads: 0 } });
|
|
574
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("attempt-merge", "mark-merged");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("a stalled probe that re-derives 'review-threads' routes to the threads arm, not the re-poll loop (#921)", async () => {
|
|
578
|
+
// The poller-dead path: the mergeable-wait timer fires, the stall probe re-derives ground truth,
|
|
579
|
+
// and a `review-threads` verdict takes the same dedicated arm a live poller message would.
|
|
580
|
+
const engine = await boot({
|
|
581
|
+
responses: {
|
|
582
|
+
"pr.merge-stall-probe": { mergeState: "review-threads", failingChecks: 0, failingChecksList: "", reviewThreads: 3 },
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
586
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
587
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
588
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-threads");
|
|
589
|
+
const q = String(escalation(engine).question ?? "");
|
|
590
|
+
assertStringIncludes(q, "3 unresolved review thread(s)", "the probe path must carry the thread count into the question");
|
|
591
|
+
assert(!completedElementIds(engine).has("wait-mergeable-repoll"), "a settled review-threads verdict must not re-poll");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("a stall-budget exhaustion on a settled BLOCKED with zero threads names the review requirement, not 'never settled' (#921)", async () => {
|
|
595
|
+
// GitHub settled: MERGEABLE/BLOCKED, checks green (none failing, none pending), every thread
|
|
596
|
+
// resolved — the block is a required human review approval. The probe reports `waiting` (the
|
|
597
|
+
// conservative verdict) with the probed counts, and the exhausted-budget escalation must name
|
|
598
|
+
// THAT cause.
|
|
599
|
+
const engine = await boot({
|
|
600
|
+
vars: { mergeStallMax: 0 },
|
|
601
|
+
responses: {
|
|
602
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
606
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
607
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
608
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
609
|
+
const q = String(escalation(engine).question ?? "");
|
|
610
|
+
assertStringIncludes(q, "required review approval", "a settled BLOCKED with clear threads must name the review requirement");
|
|
611
|
+
assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("a stall-budget exhaustion with checks still pending must NOT claim a verified review-approval blocker (PR #922 review)", async () => {
|
|
615
|
+
// Zero failing checks + zero unresolved threads, but a check is still queued/running: the BLOCKED
|
|
616
|
+
// cause is undetermined (the pending run may come back red), so the escalation must NOT assert
|
|
617
|
+
// "all checks green … required review approval" — it keeps the conservative unsettled wording.
|
|
618
|
+
const engine = await boot({
|
|
619
|
+
vars: { mergeStallMax: 0 },
|
|
620
|
+
responses: {
|
|
621
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 1 },
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
625
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
626
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
627
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
628
|
+
const q = String(escalation(engine).question ?? "");
|
|
629
|
+
assert(!q.includes("required review approval"), "a pending check must never read as a verified review-approval block");
|
|
630
|
+
assertStringIncludes(q, "never settled", "an undetermined BLOCKED keeps the conservative unsettled wording");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("the 'never settled' question is reserved for a genuinely unreadable/unsettled mergeability (#921)", async () => {
|
|
634
|
+
// mergeStallMax: 0 + a probe that still reads `waiting` with NOTHING probed (reviewThreads -1,
|
|
635
|
+
// checks unenumerable) — GitHub genuinely never settled (or the state could not be read). Only
|
|
636
|
+
// this case keeps the original message.
|
|
637
|
+
const engine = await boot({
|
|
638
|
+
vars: { mergeStallMax: 0 },
|
|
639
|
+
responses: {
|
|
640
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1 },
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
644
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
645
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
646
|
+
assertStringIncludes(
|
|
647
|
+
String(escalation(engine).question ?? ""),
|
|
648
|
+
"never settled",
|
|
649
|
+
"a genuinely unsettled/unreadable mergeability keeps the original message",
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
524
653
|
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
525
654
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
526
655
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Red/green for issue #916 — the effective merge policy must be VISIBLE on every PR status row.
|
|
2
|
+
//
|
|
3
|
+
// The defect: whether a PR run will merge was explicit nowhere an operator looks. The
|
|
4
|
+
// convergence-loop instance carried only the INVERTED legacy `convergeOnly` variable (a feature
|
|
5
|
+
// run's `autoMerge: true` became `convergeOnly: false`, so searching the loop's variables for
|
|
6
|
+
// `autoMerge` found nothing and it looked as if auto-merge had been dropped), and the `/status`
|
|
7
|
+
// projection (`activePrs`) carried no merge-intent field at all — `waiting_merge` only appears once
|
|
8
|
+
// merging has STARTED. An operator concluded auto-merge was lost and wrote `autoMerge=true` onto a
|
|
9
|
+
// running loop — a no-op write, because the loop had `convergeOnly=false` from the start.
|
|
10
|
+
//
|
|
11
|
+
// The fix projects a structured `merge` block onto every ActivePr:
|
|
12
|
+
// merge.requested — the run's merge intent: the canonical positive `autoMerge` process variable
|
|
13
|
+
// when present, falling back to the INVERSE of the legacy `convergeOnly` variable for in-flight
|
|
14
|
+
// loops seeded before `autoMerge` existed (and to `true` — the historical default — when the
|
|
15
|
+
// instance key is unreadable or carries neither variable).
|
|
16
|
+
// merge.enabled — the deployment's global `NANO_PR_AUTO_MERGE` switch.
|
|
17
|
+
// merge.effective — requested && enabled && !humanApproval: the run merges with no human gate.
|
|
18
|
+
// merge.blockedBy — "env" | "human-approval" | null: WHY a requested merge will not proceed.
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import { assertEquals } from "#test-assert";
|
|
21
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
22
|
+
import { activePrs } from "./service.ts";
|
|
23
|
+
|
|
24
|
+
function memTable(rows: any[], key: string) {
|
|
25
|
+
return {
|
|
26
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
27
|
+
all: () => Promise.resolve([...rows]),
|
|
28
|
+
find: (q: any) =>
|
|
29
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
30
|
+
findOne: (q: any) =>
|
|
31
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
32
|
+
insert: (r: any) => {
|
|
33
|
+
rows.push(r);
|
|
34
|
+
return Promise.resolve(r);
|
|
35
|
+
},
|
|
36
|
+
update: (k: any, patch: any) => {
|
|
37
|
+
const r = rows.find((x) => x[key] === k);
|
|
38
|
+
if (r) Object.assign(r, patch);
|
|
39
|
+
return Promise.resolve(r);
|
|
40
|
+
},
|
|
41
|
+
delete: (k: any) => {
|
|
42
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Stores = Record<string, { rows: any[]; key: string }>;
|
|
49
|
+
|
|
50
|
+
function memData(stores: Stores) {
|
|
51
|
+
return {
|
|
52
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
53
|
+
} as any;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A fake engine whose `searchVariables` serves the given per-instance variables. */
|
|
57
|
+
function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
|
|
58
|
+
return {
|
|
59
|
+
searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
|
|
60
|
+
const vars = varsByInstance[String(q.processInstanceKey)] ?? {};
|
|
61
|
+
return Promise.resolve(
|
|
62
|
+
Object.entries(vars)
|
|
63
|
+
.filter(([name]) => q.name == null || name === q.name)
|
|
64
|
+
.map(([name, v]) => ({ name, value: JSON.stringify(v) })),
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
} as any;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function prRow(over: Record<string, unknown>) {
|
|
71
|
+
return {
|
|
72
|
+
pr_key: "owner/repo#1",
|
|
73
|
+
repo: "owner/repo",
|
|
74
|
+
number: 1,
|
|
75
|
+
url: "https://github.com/owner/repo/pull/1",
|
|
76
|
+
title: "t",
|
|
77
|
+
status: "converging",
|
|
78
|
+
current_round: 1,
|
|
79
|
+
process_key: "PI-1",
|
|
80
|
+
updated_at: "2024-01-01",
|
|
81
|
+
...over,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function mergeOf(vars: Record<string, unknown>, rowOver: Record<string, unknown> = {}) {
|
|
86
|
+
return mergeOfWith({ activePrs }, vars, rowOver);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `mergeOf` against an explicitly-loaded service module — for the env-switched tests that import
|
|
90
|
+
* a fresh copy of app/service.ts (e.g. `NANO_PR_AUTO_MERGE=0`). */
|
|
91
|
+
async function mergeOfWith(
|
|
92
|
+
mod: Pick<typeof import("./service.ts"), "activePrs">,
|
|
93
|
+
vars: Record<string, unknown>,
|
|
94
|
+
rowOver: Record<string, unknown> = {},
|
|
95
|
+
) {
|
|
96
|
+
const stores: Stores = {
|
|
97
|
+
pull_requests: { rows: [prRow(rowOver)], key: "pr_key" },
|
|
98
|
+
user_tasks: { rows: [], key: "id" },
|
|
99
|
+
};
|
|
100
|
+
const [pr] = await mod.activePrs(memData(stores), fakeEngine({ "PI-1": vars }));
|
|
101
|
+
return pr.merge;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// AUTO_MERGE is computed once at app/service.ts import time; the test env leaves NANO_PR_AUTO_MERGE
|
|
105
|
+
// unset, so the global switch is ON here (the default).
|
|
106
|
+
|
|
107
|
+
test("merge policy: a loop seeded autoMerge=true reads requested+effective (the #916 phantom)", async () => {
|
|
108
|
+
const merge = await mergeOf({ autoMerge: true, convergeOnly: false, humanApproval: false });
|
|
109
|
+
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("merge policy: a legacy in-flight loop (convergeOnly only) derives requested from its inverse", async () => {
|
|
113
|
+
// Seeded BEFORE `autoMerge` existed: only the legacy inverted variable is present.
|
|
114
|
+
const review = await mergeOf({ convergeOnly: true });
|
|
115
|
+
assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null });
|
|
116
|
+
const merging = await mergeOf({ convergeOnly: false });
|
|
117
|
+
assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("merge policy: humanApproval gates the effective merge and is named as the blocker", async () => {
|
|
121
|
+
const merge = await mergeOf({ autoMerge: true, humanApproval: true });
|
|
122
|
+
assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("merge policy: an approved PR with finalization still pending reports no blocker (PR #917 review)", async () => {
|
|
126
|
+
// The `merge-approval` user task clears the loop's `humanApproval` variable on an explicit
|
|
127
|
+
// "approve" (convergence-loop.bpmn), so while `pr.finalize` is still queued or incidented the
|
|
128
|
+
// projection reads humanApproval=false and must NOT keep reporting a human-approval blocker for
|
|
129
|
+
// an approval that is already complete. (`status` stays non-terminal: `converged_at` is only
|
|
130
|
+
// stamped once `pr.finalize` actually runs.)
|
|
131
|
+
const merge = await mergeOf({ autoMerge: true, humanApproval: false }, { status: "converging" });
|
|
132
|
+
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("merge policy: a review-only request reads requested=false (distinct from env/human blocking)", async () => {
|
|
136
|
+
const merge = await mergeOf({ autoMerge: false, convergeOnly: true });
|
|
137
|
+
assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("merge policy: no readable instance key (or neither variable) defaults to the historical merge default", async () => {
|
|
141
|
+
// A mid-handoff row (process_key null) or a legacy loop carrying neither variable: the pre-#916
|
|
142
|
+
// behaviour merged by default, so the projection must NOT read such a PR as review-only.
|
|
143
|
+
const stores: Stores = {
|
|
144
|
+
pull_requests: { rows: [prRow({ process_key: null })], key: "pr_key" },
|
|
145
|
+
user_tasks: { rows: [], key: "id" },
|
|
146
|
+
};
|
|
147
|
+
const [noKey] = await activePrs(memData(stores), fakeEngine({}));
|
|
148
|
+
assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
149
|
+
const bare = await mergeOf({});
|
|
150
|
+
assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never a review-only one", async () => {
|
|
154
|
+
// `AUTO_MERGE` is read once at app/service.ts import time, so load a fresh copy of the module
|
|
155
|
+
// under the env (the same pattern app/service.test.ts uses) — every other test here runs with
|
|
156
|
+
// the switch ON (the default) and none exercised `blockedBy: "env"` before this one.
|
|
157
|
+
const prev = process.env["NANO_PR_AUTO_MERGE"];
|
|
158
|
+
process.env["NANO_PR_AUTO_MERGE"] = "0";
|
|
159
|
+
try {
|
|
160
|
+
const mod: typeof import("./service.ts") = await import(`./service.ts?mergeStatusAutoMergeOff=${Date.now()}`);
|
|
161
|
+
const requested = await mergeOfWith(mod, { autoMerge: true, humanApproval: true });
|
|
162
|
+
assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env" });
|
|
163
|
+
const reviewOnly = await mergeOfWith(mod, { autoMerge: false });
|
|
164
|
+
assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null });
|
|
165
|
+
} finally {
|
|
166
|
+
if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
|
|
167
|
+
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
168
|
+
}
|
|
169
|
+
});
|
package/app/readiness.ts
CHANGED
|
@@ -768,6 +768,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
768
768
|
isDraft: j.isDraft === true,
|
|
769
769
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
770
770
|
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
771
|
+
unresolvedThreads: -1, // …nor the review-threads probe (merge-stage-only signal, #921) → unprobed
|
|
771
772
|
mergedSha,
|
|
772
773
|
pendingChecks: pending.length,
|
|
773
774
|
};
|
package/app/service.test.ts
CHANGED
|
@@ -1005,6 +1005,31 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
1005
1005
|
});
|
|
1006
1006
|
});
|
|
1007
1007
|
|
|
1008
|
+
// Issue #916: the loop must ALSO carry the POSITIVE `autoMerge` variable — an operator searching a
|
|
1009
|
+
// live convergence loop's variables for `autoMerge` previously found nothing (only the inverted
|
|
1010
|
+
// `convergeOnly`), and concluded auto-merge had been dropped. `submitPr` seeds both, keeping them
|
|
1011
|
+
// consistent: `autoMerge` is canonical, `convergeOnly` its inverse mirror for in-flight compat.
|
|
1012
|
+
test("submitPr seeds the positive autoMerge variable alongside the convergeOnly mirror (issue #916)", async () => {
|
|
1013
|
+
await withGithubOff(async () => {
|
|
1014
|
+
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|
|
1015
|
+
// Legacy positional form: convergeOnly=true ⇒ autoMerge=false (both seeded, consistent).
|
|
1016
|
+
const legacy = captureVars();
|
|
1017
|
+
await submitPr(legacy.data, legacy.engine, pr(30), [], 20, true);
|
|
1018
|
+
assertEquals(legacy.get()?.autoMerge, false);
|
|
1019
|
+
assertEquals(legacy.get()?.convergeOnly, true);
|
|
1020
|
+
// Default (no override): merge intent on — the global default governs.
|
|
1021
|
+
const dflt = captureVars();
|
|
1022
|
+
await submitPr(dflt.data, dflt.engine, pr(31));
|
|
1023
|
+
assertEquals(dflt.get()?.autoMerge, true);
|
|
1024
|
+
assertEquals(dflt.get()?.convergeOnly, false);
|
|
1025
|
+
// The positive trailing form wins over the legacy alias when both are supplied.
|
|
1026
|
+
const both = captureVars();
|
|
1027
|
+
await submitPr(both.data, both.engine, pr(32), [], 20, true, null, false, false, true);
|
|
1028
|
+
assertEquals(both.get()?.autoMerge, true);
|
|
1029
|
+
assertEquals(both.get()?.convergeOnly, false);
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1008
1033
|
// #796 auto-ack budget seeding: `submitPr` is the ONLY production write that makes the retry budget
|
|
1009
1034
|
// available to a fresh convergence instance — the engine behaviour tests seed `ackRetryRound` /
|
|
1010
1035
|
// `ackRetryMax` directly and never exercise `submitPr`, so a regression dropping or misconfiguring
|