@nanobpm/nano-workforce 0.49.0 → 0.50.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 +7 -0
- package/package.json +2 -2
- package/workers/merge/worker.test.ts +112 -0
- package/workers/merge/worker.ts +23 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.50.0](https://github.com/nanobpm/nano-workforce/compare/v0.49.0...v0.50.0) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **merge:** adopt @nanobpm/urban/effect matchTags for exhaustive land dispatch ([#139](https://github.com/nanobpm/nano-workforce/issues/139)) ([c251763](https://github.com/nanobpm/nano-workforce/commit/c251763a050858ea610ce45e59a34f859163c29c)), closes [nano-ide#215](https://github.com/nano-ide/issues/215)
|
|
7
|
+
|
|
1
8
|
# [0.49.0](https://github.com/nanobpm/nano-workforce/compare/v0.48.2...v0.49.0) (2026-08-12)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@nanobpm/urban": "^0.
|
|
49
|
+
"@nanobpm/urban": "^0.45.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@biomejs/biome": "^2.4.11",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// token transport and stubs `globalThis.fetch` so the single-PR GET reports `merged: true`.
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
|
+
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
9
10
|
import { noopLog } from "../../test/log.ts";
|
|
10
11
|
import handler from "./worker.ts";
|
|
11
12
|
|
|
@@ -95,3 +96,114 @@ test("pr.merge short-circuits an already-merged PR without re-running the land p
|
|
|
95
96
|
assertEquals(calls.some((u) => /comments|merge$/.test(u)), false);
|
|
96
97
|
});
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
// The land protocol has three terminal outcomes, dispatched by the worker's exhaustive `matchTags`
|
|
101
|
+
// (worker.ts §"Exhaustive dispatch"). The already-merged short-circuit above never reaches that
|
|
102
|
+
// dispatch, so the two land branches below — `queued` (repo lands via an on-demand merge queue) and
|
|
103
|
+
// `blocked` (GitHub refuses the merge) — pin the behaviour of that critical terminal switch so a
|
|
104
|
+
// regression in any arm is caught. Both drive the token transport and route GitHub calls through a
|
|
105
|
+
// stubbed `globalThis.fetch`.
|
|
106
|
+
// Each recorded call keeps the HTTP method alongside the URL so tests can assert not just *which*
|
|
107
|
+
// endpoint the worker hit but *how* (e.g. enqueue via POST, merge via PUT) — a URL-only matcher
|
|
108
|
+
// would stay green if the verb regressed.
|
|
109
|
+
type GithubCall = { url: string; method: string };
|
|
110
|
+
|
|
111
|
+
function withGithub(
|
|
112
|
+
routes: (url: string, init: RequestInit | undefined) => Response | null,
|
|
113
|
+
run: (calls: GithubCall[]) => Promise<void>,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
116
|
+
const oldToken = process.env["GITHUB_TOKEN"];
|
|
117
|
+
const oldFetch = globalThis.fetch;
|
|
118
|
+
const calls: GithubCall[] = [];
|
|
119
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
120
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
121
|
+
_clearMergeProtocolCache();
|
|
122
|
+
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
|
123
|
+
const url = String(input);
|
|
124
|
+
calls.push({ url, method: (init?.method ?? "GET").toUpperCase() });
|
|
125
|
+
const res = routes(url, init);
|
|
126
|
+
return Promise.resolve(res ?? new Response("not found", { status: 404 }));
|
|
127
|
+
}) as typeof fetch;
|
|
128
|
+
return run(calls).finally(() => {
|
|
129
|
+
if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
130
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
|
|
131
|
+
if (oldToken == null) delete process.env["GITHUB_TOKEN"];
|
|
132
|
+
else process.env["GITHUB_TOKEN"] = oldToken;
|
|
133
|
+
globalThis.fetch = oldFetch;
|
|
134
|
+
_clearMergeProtocolCache();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test("pr.merge routes a mergify-queue repo through the queued branch (enqueue comment, status=queued)", async () => {
|
|
139
|
+
// AGENTS.md publishes a mergify-queue land protocol, so the worker enqueues via a comment rather
|
|
140
|
+
// than issuing a direct merge; the queued arm of `matchTags` marks the PR `queued` and returns.
|
|
141
|
+
const protocol =
|
|
142
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
143
|
+
await withGithub(
|
|
144
|
+
(url) => {
|
|
145
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
146
|
+
if (/\/pulls\/\d+$/.test(url)) return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
|
|
147
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
148
|
+
return null;
|
|
149
|
+
},
|
|
150
|
+
async (calls) => {
|
|
151
|
+
const { app, stores } = fakeApp();
|
|
152
|
+
const out = (await handler(
|
|
153
|
+
{ variables: { prKey: "acme/widgets#7", repo: "acme/widgets", prNumber: 7 } } as any,
|
|
154
|
+
app,
|
|
155
|
+
)) as Record<string, unknown>;
|
|
156
|
+
|
|
157
|
+
// Queued arm: waits for `merge-landed`, so it reports `queued` (not `merged`/`blocked`).
|
|
158
|
+
assertEquals(out, { mergeStatus: "queued" });
|
|
159
|
+
|
|
160
|
+
// Enqueued via the protocol's comment (POST), never a direct merge PUT.
|
|
161
|
+
assertEquals(
|
|
162
|
+
calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"),
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
166
|
+
|
|
167
|
+
// Audit row records the queue-comment land, and the PR row is flipped to `queued`.
|
|
168
|
+
assertEquals(stores.merges.length, 1);
|
|
169
|
+
assertEquals(stores.merges[0].outcome, "queued");
|
|
170
|
+
assertEquals(stores.merges[0].method, "queue-comment");
|
|
171
|
+
assertEquals(stores.pull_requests.find((r) => r.pr_key === "acme/widgets#7")?.status, "queued");
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("pr.merge routes a refused merge through the blocked branch (escalation payload)", async () => {
|
|
177
|
+
// Default (gh-merge) protocol; GitHub refuses the merge PUT, so `mergePr` reports `blocked` and the
|
|
178
|
+
// blocked arm of `matchTags` shapes the human-facing escalation question from the failure detail.
|
|
179
|
+
await withGithub(
|
|
180
|
+
(url) => {
|
|
181
|
+
if (/\/pulls\/\d+\/merge$/.test(url))
|
|
182
|
+
return new Response("Pull Request is not mergeable", { status: 405, statusText: "Method Not Allowed" });
|
|
183
|
+
if (/\/pulls\/\d+$/.test(url)) return new Response(JSON.stringify({ merged: false, mergeable_state: "dirty" }));
|
|
184
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
185
|
+
},
|
|
186
|
+
async (calls) => {
|
|
187
|
+
const { app, stores } = fakeApp();
|
|
188
|
+
const out = (await handler(
|
|
189
|
+
{ variables: { prKey: "acme/widgets#9", repo: "acme/widgets", prNumber: 9 } } as any,
|
|
190
|
+
app,
|
|
191
|
+
)) as Record<string, unknown>;
|
|
192
|
+
|
|
193
|
+
// Blocked arm: surfaces both the loop-terminal `mergeStatus` and the escalation `status`.
|
|
194
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
195
|
+
assertEquals(out.status, "blocked");
|
|
196
|
+
assertEquals(typeof out.question, "string");
|
|
197
|
+
assertEquals((out.question as string).startsWith("Automated merge was blocked:"), true);
|
|
198
|
+
|
|
199
|
+
// Attempted a real merge PUT (not an enqueue comment), and recorded a blocked audit row.
|
|
200
|
+
assertEquals(
|
|
201
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
205
|
+
assertEquals(stores.merges.length, 1);
|
|
206
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
});
|
package/workers/merge/worker.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
|
|
10
10
|
// shapes the escalation payload on a block.
|
|
11
11
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
|
+
import { matchTags, tag } from "@nanobpm/urban/effect";
|
|
12
13
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
13
14
|
import { checkBaseTarget } from "../../app/baseGuard.ts";
|
|
14
15
|
import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
|
|
@@ -134,25 +135,29 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
134
135
|
at: now,
|
|
135
136
|
});
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
return { mergeStatus: "queued" };
|
|
143
|
-
}
|
|
144
|
-
if (outcome === "merged") {
|
|
145
|
-
return { mergeStatus: "merged" };
|
|
146
|
-
}
|
|
147
|
-
// blocked → hand the escalation machinery a concrete question.
|
|
138
|
+
// Exhaustive dispatch on the land outcome. Modelled as a tagged value so
|
|
139
|
+
// `matchTags` forces a handler for every case — adding a new outcome to the
|
|
140
|
+
// `"merged" | "queued" | "blocked"` union becomes a compile error here rather
|
|
141
|
+
// than silently falling through to the "blocked" branch.
|
|
148
142
|
const docHint = protocol?.doc ? ` See the repo's merge protocol (${protocol.doc}).` : "";
|
|
149
|
-
return {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
143
|
+
return await matchTags(tag(outcome, { detail }), {
|
|
144
|
+
queued: async () => {
|
|
145
|
+
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
146
|
+
status: "queued",
|
|
147
|
+
updated_at: now,
|
|
148
|
+
});
|
|
149
|
+
return { mergeStatus: "queued" };
|
|
150
|
+
},
|
|
151
|
+
merged: async () => ({ mergeStatus: "merged" }),
|
|
152
|
+
// blocked → hand the escalation machinery a concrete question.
|
|
153
|
+
blocked: async (o) => ({
|
|
154
|
+
mergeStatus: "blocked",
|
|
155
|
+
status: "blocked",
|
|
156
|
+
question:
|
|
157
|
+
`Automated merge was blocked: ${o.detail}. ` +
|
|
158
|
+
`Resolve it on GitHub (rebase / fix a required check / grant merge rights), then reply to retry.${docHint}`,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
156
161
|
};
|
|
157
162
|
|
|
158
163
|
export default handler;
|