@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 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.49.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.44.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
+ });
@@ -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
- if (outcome === "queued") {
138
- await app.data.table("pull_requests", "pr_key").update(prKey, {
139
- status: "queued",
140
- updated_at: now,
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
- mergeStatus: "blocked",
151
- status: "blocked",
152
- question:
153
- `Automated merge was blocked: ${detail}. ` +
154
- `Resolve it on GitHub (rebase / fix a required check / grant merge rights), then reply to retry.${docHint}`,
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;