@nanobpm/nano-workforce 0.56.0 → 0.58.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.
Files changed (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -0,0 +1,44 @@
1
+ // POST /app/api/hooks/revert-completion → operationId `revertEscalationCompletion` (epic #156, slice
2
+ // U6; ADR 0046 reversibility). A completed user task cannot be un-completed in the engine, so an
3
+ // AGENT answer is never a silent irreversible commit: this endpoint lets a human mark a reversible
4
+ // agent completion reverted/overridden, recording who did it and when. Host-side consumers read the
5
+ // `task_completions` ledger to see whether the latest completion is still authoritative. Human
6
+ // completions are not reversible (they are already the authority), and a completion can be reverted
7
+ // only once. Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET
8
+ // is set.
9
+
10
+ import { revertAgentCompletion } from "../app/agentCompletion.ts";
11
+ import { envVar } from "../app/version.ts";
12
+ import { defineOperation } from "../nano-generated/operations.ts";
13
+
14
+ const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
15
+
16
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
17
+
18
+ export default defineOperation("revertEscalationCompletion", async ({ req, body }, app) => {
19
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
20
+ app.log.warn("revert-completion rejected: missing/invalid shared secret");
21
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
22
+ }
23
+ if (!body || typeof body !== "object") {
24
+ app.log.warn("revert-completion rejected: missing request body");
25
+ return { status: 400, body: { ok: false, error: "completionId and reverterId are required" } };
26
+ }
27
+
28
+ const completionId = typeof body.completionId === "number" ? body.completionId : Number.NaN;
29
+ const reverterId = str(body.reverterId);
30
+ const note = str(body.note);
31
+ if (!Number.isInteger(completionId)) {
32
+ return { status: 400, body: { ok: false, error: "completionId must be an integer" } };
33
+ }
34
+ if (!reverterId) return { status: 400, body: { ok: false, error: "reverterId is required" } };
35
+
36
+ const r = await revertAgentCompletion(app.data, completionId, { kind: "human", id: reverterId }, note);
37
+ if (r.ok) {
38
+ app.log.info("agent completion reverted", { completionId, reverterId });
39
+ return { status: 200, body: { ok: true, completionId: r.completionId } };
40
+ }
41
+ const status = r.reason === "no such completion" ? 404 : 400;
42
+ app.log.warn("revert-completion: not reverted", { completionId, reason: r.reason });
43
+ return { status, body: { ok: false, error: r.reason } };
44
+ });
@@ -12,6 +12,7 @@ import { noopLog } from "../test/log.ts";
12
12
  import startConvergenceLoop from "./startConvergenceLoop.ts";
13
13
  import startPlanFanout from "./startPlanFanout.ts";
14
14
  import postMessage from "./postMessage.ts";
15
+ import { resetDefaultBranchCache } from "../app/github.ts";
15
16
 
16
17
  const app = { log: noopLog() } as any as AppApi;
17
18
 
@@ -72,13 +73,60 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
72
73
  const prevTok = process.env["GITHUB_TOKEN"];
73
74
  process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token → meta fetch is skipped
74
75
  delete process.env["GITHUB_TOKEN"];
76
+ resetDefaultBranchCache(); // start cold so a prior warmed cache can't mask the no-transport path
75
77
  return run().finally(() => {
78
+ resetDefaultBranchCache();
76
79
  if (prev !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prev;
77
80
  else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
78
81
  if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
79
82
  });
80
83
  }
81
84
 
85
+ // Force the token transport with a stubbed `globalThis.fetch` that serves a minimal in-memory
86
+ // github model, so `admitPlan` (which now calls `ensureBaseBranch` + `fetchDefaultBranch`) can run
87
+ // without touching the network. The default branch is `main`; an epic/* base is auto-created off it.
88
+ function withGithubStub(run: () => Promise<void>): Promise<void> {
89
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
90
+ const prevTok = process.env["GITHUB_TOKEN"];
91
+ const prevFetch = globalThis.fetch;
92
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
93
+ process.env["GITHUB_TOKEN"] = "tok";
94
+ resetDefaultBranchCache(); // isolate: don't inherit or leak the owner/repo default-branch entry
95
+ const branches = new Map<string, string>([["main", "mainsha"]]);
96
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
97
+ const u = new URL(String(url));
98
+ const method = (init?.method ?? "GET").toUpperCase();
99
+ const path = u.pathname;
100
+ const json = (obj: unknown, status = 200) =>
101
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
102
+ if (method === "GET" && path === "/repos/owner/repo") return Promise.resolve(json({ default_branch: "main" }));
103
+ const refPrefix = "/repos/owner/repo/git/ref/heads/";
104
+ if (method === "GET" && path.startsWith(refPrefix)) {
105
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
106
+ const sha = branches.get(branch);
107
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
108
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha } }));
109
+ }
110
+ if (method === "POST" && path === "/repos/owner/repo/git/refs") {
111
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
112
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
113
+ const branch = String(body.ref ?? "").replace(/^refs\/heads\//, "");
114
+ if (branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
115
+ branches.set(branch, String(body.sha ?? ""));
116
+ return Promise.resolve(json({ ref: body.ref }, 201));
117
+ }
118
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
119
+ }) as typeof fetch;
120
+ return run().finally(() => {
121
+ resetDefaultBranchCache();
122
+ globalThis.fetch = prevFetch;
123
+ if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
124
+ else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
125
+ if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
126
+ else delete process.env["GITHUB_TOKEN"];
127
+ });
128
+ }
129
+
82
130
  test("startConvergenceLoop → 400 (not 500) on a missing request body", async () => {
83
131
  const res = await startConvergenceLoop(input(undefined), app);
84
132
  const r = res as any;
@@ -133,6 +181,15 @@ test("startPlanFanout → 400 on an invalid baseBranch (not persisted/rendered)"
133
181
  assertEquals(typeof r.body.error, "string");
134
182
  });
135
183
 
184
+ test("startPlanFanout → 400 on a missing baseBranch (not persisted/rendered)", async () => {
185
+ // A blank/absent baseBranch must be rejected at the edge as a 400 (MissingBaseBranchError),
186
+ // never silently coalesced to the repository default branch (ADR 0003, B0).
187
+ const res = await startPlanFanout(input({ issue: "owner/repo#123" }), app);
188
+ const r = res as any;
189
+ assertEquals(r.status, 400);
190
+ assertEquals(typeof r.body.error, "string");
191
+ });
192
+
136
193
  test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
137
194
  await withGithubOff(async () => {
138
195
  const { app: capApp } = captureApp();
@@ -142,9 +199,12 @@ test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () =>
142
199
  });
143
200
 
144
201
  test("startPlanFanout narrows the `url` variant (no `issue` key)", async () => {
145
- await withGithubOff(async () => {
202
+ await withGithubStub(async () => {
146
203
  const { app: capApp } = captureApp();
147
- const res = await startPlanFanout(input({ url: "https://github.com/owner/repo/issues/12" }), capApp);
204
+ const res = await startPlanFanout(
205
+ input({ url: "https://github.com/owner/repo/issues/12", baseBranch: "epic/agent-protocol" }),
206
+ capApp,
207
+ );
148
208
  assertEquals((res as any).status, 202);
149
209
  });
150
210
  });
@@ -162,61 +222,3 @@ test("postMessage → 400 when escalation-answered lacks a correlationKey", asyn
162
222
  assertEquals(r.status, 400);
163
223
  assertEquals(r.body.error, "correlationKey is required");
164
224
  });
165
-
166
- function planEscalationMessageApp() {
167
- const plans = [{ plan_key: "owner/repo#12", open_plan_escalation_id: 1 }];
168
- const escalations = [{
169
- id: 1,
170
- plan_key: "owner/repo#12",
171
- epoch: 0,
172
- round: 2,
173
- findings: "needs guidance",
174
- status: "open",
175
- directive: null,
176
- note: null,
177
- }];
178
- const published: any[] = [];
179
- const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
180
- Object.entries(q).every(([f, v]) => r[f] === v);
181
- const table = (rows: any[], key: string) => ({
182
- find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
183
- update: (id: any, patch: any) => {
184
- const row = rows.find((r) => r[key] === id);
185
- if (row) Object.assign(row, patch);
186
- return Promise.resolve(row);
187
- },
188
- });
189
- return {
190
- app: {
191
- data: {
192
- table(name: string) {
193
- return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
194
- },
195
- },
196
- engine: {
197
- publishMessage: (m: any) => {
198
- published.push(m);
199
- return Promise.resolve();
200
- },
201
- },
202
- log: noopLog(),
203
- } as any as AppApi,
204
- escalations,
205
- published,
206
- };
207
- }
208
-
209
- test("postMessage accepts mixed-case plan escalation directive like the dedicated hook", async () => {
210
- const { app: msgApp, escalations, published } = planEscalationMessageApp();
211
- const res = await postMessage(input({
212
- name: "plan-escalation-answered",
213
- correlationKey: "owner/repo#12",
214
- variables: { directive: "PrOcEeD", note: "ship it" },
215
- }), msgApp);
216
- const r = res as any;
217
- assertEquals(r.status, 200);
218
- assertEquals(r.body.ok, true);
219
- assertEquals(r.body.directive, "proceed");
220
- assertEquals(escalations[0].directive, "proceed");
221
- assertEquals(published[0].variables.planEscalationDirective, "proceed");
222
- });
@@ -0,0 +1,127 @@
1
+ // POST /app/api/actions/start/feature → operationId `startFeature` (ADR 0058/0059, base /app/api).
2
+ // The ONE door for starting a SINGLE-issue feature run — the "missing middle" between Epics
3
+ // (startPlanFanout) and PR convergence (startConvergenceLoop): hand one issue to a single
4
+ // implementation agent that raises exactly one PR, then OPTIONALLY converge + merge (issue #172).
5
+ //
6
+ // The request body is FLAT (`{ issue | url, baseBranch, converge?, autoMerge? }`), not wrapped in a
7
+ // `variables` envelope — a purpose-built operation, not a generic engine "start process" call. The
8
+ // body is a `oneOf` — EXACTLY ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at
9
+ // the edge; this delegate narrows the validated variant and keeps the issue-FORMAT parse guard.
10
+ //
11
+ // Base-branch handling is IDENTICAL to the epic path: it reuses `admitPlan` (ADR 0003) verbatim, so
12
+ // a feature run's PR is admitted through the same required+explicit / create-if-missing /
13
+ // confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
14
+
15
+ import { startFeature } from "../app/feature.ts";
16
+ import { BaseBranchMustExistError } from "../app/github.ts";
17
+ import {
18
+ admitPlan,
19
+ DefaultBaseNotConfirmedError,
20
+ InvalidBaseBranchError,
21
+ MissingBaseBranchError,
22
+ parseIssue,
23
+ SharedBaseError,
24
+ } from "../app/plan.ts";
25
+ import { defineOperation } from "../nano-generated/operations.ts";
26
+
27
+ export default defineOperation("startFeature", async ({ body }, app) => {
28
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
29
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
30
+ if (!body || typeof body !== "object") {
31
+ app.log.warn("start-feature rejected: missing request body");
32
+ return { status: 400, body: { error: "request body is required (owner/repo#123 or an issue URL)" } };
33
+ }
34
+ // The `oneOf` variant is normally narrowed by OpenAPI validation, but a directly-invoked delegate
35
+ // can pass a missing/mistyped `issue`/`url` — guard so that stays a 400, not a 500 from `.trim()`.
36
+ const target = "issue" in body ? body.issue : "url" in body ? body.url : undefined;
37
+ if (typeof target !== "string") {
38
+ app.log.warn("start-feature rejected: issue/url must be a string");
39
+ return { status: 400, body: { error: "issue or url must be a string (owner/repo#123 or an issue URL)" } };
40
+ }
41
+ const raw = target.trim();
42
+ const parsed = parseIssue(raw);
43
+ if (!parsed) {
44
+ app.log.warn("start-feature rejected: unparseable issue reference", { raw });
45
+ return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
46
+ }
47
+ // Base branch (ADR 0003): admit the launch through the same fail-fast `admitPlan` gate the epic
48
+ // path uses BEFORE starting the run. A feature run also raises a PR against a base, so it honors
49
+ // the identical admission policy and error → HTTP mapping.
50
+ const rawBase = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
51
+ const allowSharedBase = "allowSharedBase" in body && body.allowSharedBase === true;
52
+ const confirmDefaultBase = "confirmDefaultBase" in body && body.confirmDefaultBase === true;
53
+ const token = process.env.GITHUB_TOKEN ?? "";
54
+ let normalizedBase: string;
55
+ try {
56
+ // No `selfPlanKey`: a feature run creates a `feature_runs` row, NOT a `plans` row, so there is no
57
+ // own plan to exclude from the shared-base guard (rule 4). Passing `parsed.planKey` here would
58
+ // exclude an ACTIVE EPIC sharing the same `owner/repo#N` key, silently bypassing shared-base
59
+ // protection. Feature-run idempotency is enforced separately by `startFeature` on `feature_runs`.
60
+ normalizedBase = await admitPlan(app.data, parsed.repo, rawBase, token, {
61
+ allowSharedBase,
62
+ confirmDefaultBase,
63
+ });
64
+ } catch (err) {
65
+ if (err instanceof MissingBaseBranchError) {
66
+ app.log.warn("start-feature rejected: missing base branch");
67
+ return {
68
+ status: 400,
69
+ body: { error: "baseBranch is required (name the branch the PR targets, e.g. main or epic/agent-protocol)" },
70
+ };
71
+ }
72
+ if (err instanceof InvalidBaseBranchError) {
73
+ app.log.warn("start-feature rejected: invalid base branch", { baseBranch: err.value });
74
+ return {
75
+ status: 400,
76
+ body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. main)" },
77
+ };
78
+ }
79
+ if (err instanceof BaseBranchMustExistError) {
80
+ app.log.warn("start-feature rejected: base branch does not exist", { baseBranch: err.branch });
81
+ return {
82
+ status: 400,
83
+ body: {
84
+ error:
85
+ `baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
86
+ `auto-created — create it first, or use the epic/* convention`,
87
+ },
88
+ };
89
+ }
90
+ if (err instanceof DefaultBaseNotConfirmedError) {
91
+ app.log.warn("start-feature rejected: default base not confirmed", { baseBranch: err.branch });
92
+ return {
93
+ status: 400,
94
+ body: {
95
+ error:
96
+ `baseBranch "${err.branch}" is the repository default branch — the PR would target it ` +
97
+ `directly. Re-submit with confirmDefaultBase: true to proceed`,
98
+ },
99
+ };
100
+ }
101
+ if (err instanceof SharedBaseError) {
102
+ app.log.warn("start-feature rejected: shared base branch", { baseBranch: err.branch });
103
+ return {
104
+ status: 409,
105
+ body: {
106
+ error:
107
+ `baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
108
+ `allowSharedBase: true to stack on it, or name a distinct branch`,
109
+ },
110
+ };
111
+ }
112
+ throw err;
113
+ }
114
+ const converge = "converge" in body && body.converge === true;
115
+ // Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
116
+ // the persisted row and the process variable can't disagree.
117
+ const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
118
+ const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge);
119
+ app.log.info("feature run started", {
120
+ featureKey: parsed.planKey,
121
+ requestedBaseBranch: normalizedBase,
122
+ converge,
123
+ autoMerge,
124
+ alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
125
+ });
126
+ return { status: 202, body: result };
127
+ });
@@ -0,0 +1,263 @@
1
+ // Integration coverage for the base-branch ADMISSION gate (ADR 0003) driven through the operation
2
+ // EDGE — `startPlanFanout` → `admitPlan` → HTTP status. The unit tests in app/plan.test.ts already
3
+ // prove `admitPlan`'s decision matrix in isolation; this file proves the COMPOSED behaviour at the
4
+ // door: each admission rule maps to the correct HTTP status (400 / 409) and each accept path reaches
5
+ // the 202 fan-out. It runs the real delegate against an in-memory app/data/engine and a faked github
6
+ // transport (token mode + stubbed `globalThis.fetch`) — no network, deterministic on a single run.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { resetDefaultBranchCache } from "../app/github.ts";
11
+ import { noopLog } from "../test/log.ts";
12
+ import startPlanFanout from "./startPlanFanout.ts";
13
+
14
+ // ── in-memory github model ───────────────────────────────────────────────────
15
+ // A minimal fake of the GitHub REST surface `admitPlan` touches: the repo-meta GET (default branch),
16
+ // the ref GET (branch existence, returning a synthetic per-branch head sha) and the ref-create POST.
17
+ // `default_branch` is `main`; a missing `epic/*` base is auto-created off the default branch's head
18
+ // sha (which `admitPlan` reads via the ref GET, so here that is `main-sha`). `creates` records the
19
+ // full ref-create POST body (`ref` + `sha`) so a test can assert a branch was (or was NOT) created
20
+ // AND that the create payload points the new ref at the resolved base sha, not a stale/blank value.
21
+ interface GithubState {
22
+ repo: string;
23
+ defaultBranch: string;
24
+ branches: Set<string>;
25
+ creates: { ref: string; sha: string }[];
26
+ }
27
+
28
+ function githubFetch(state: GithubState) {
29
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
30
+ const u = new URL(String(url));
31
+ const method = (init?.method ?? "GET").toUpperCase();
32
+ const path = u.pathname;
33
+ const json = (obj: unknown, status = 200) =>
34
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
35
+ if (method === "GET" && path === `/repos/${state.repo}`) {
36
+ return Promise.resolve(json({ default_branch: state.defaultBranch }));
37
+ }
38
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
39
+ if (method === "GET" && path.startsWith(refPrefix)) {
40
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
41
+ if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
42
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
43
+ }
44
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
45
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
46
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
47
+ const ref = String(body.ref ?? "");
48
+ const sha = String(body.sha ?? "");
49
+ const branch = ref.replace(/^refs\/heads\//, "");
50
+ if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
51
+ state.creates.push({ ref, sha });
52
+ state.branches.add(branch);
53
+ return Promise.resolve(json({ ref }, 201));
54
+ }
55
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
56
+ };
57
+ }
58
+
59
+ async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
60
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
61
+ const prevTok = process.env["GITHUB_TOKEN"];
62
+ const prevFetch = globalThis.fetch;
63
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
64
+ process.env["GITHUB_TOKEN"] = "tok";
65
+ resetDefaultBranchCache(); // isolate: don't inherit or leak another test's default-branch entry
66
+ globalThis.fetch = githubFetch(state) as typeof fetch;
67
+ try {
68
+ return await fn();
69
+ } finally {
70
+ resetDefaultBranchCache();
71
+ globalThis.fetch = prevFetch;
72
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
73
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
74
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
75
+ else process.env["GITHUB_TOKEN"] = prevTok;
76
+ }
77
+ }
78
+
79
+ // ── in-memory app (data + engine) ────────────────────────────────────────────
80
+ // A generic table over an array, matching the DataLayer surface `startPlan`/`findActivePlansByBase`
81
+ // use (get/find/insert/update/delete). `seedPlans` pre-loads the `plans` table so the shared-base
82
+ // guard has active rows to find. `started` records each engine.createInstance call so an accept path
83
+ // can be asserted to have fanned out.
84
+ function makeApp(seedPlans: Record<string, unknown>[] = []) {
85
+ const tables = new Map<string, Record<string, unknown>[]>();
86
+ tables.set("plans", [...seedPlans]);
87
+ const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
88
+ const table = (name: string, key: string) => {
89
+ const rows = tables.get(name) ?? (() => {
90
+ const fresh: Record<string, unknown>[] = [];
91
+ tables.set(name, fresh);
92
+ return fresh;
93
+ })();
94
+ return {
95
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
96
+ find: (q: Record<string, unknown>) =>
97
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
98
+ insert: (r: Record<string, unknown>) => {
99
+ rows.push(r);
100
+ return Promise.resolve(r);
101
+ },
102
+ update: (k: unknown, patch: Record<string, unknown>) => {
103
+ const row = rows.find((r) => r[key] === k);
104
+ if (row) Object.assign(row, patch);
105
+ return Promise.resolve(row);
106
+ },
107
+ delete: (k: unknown) => {
108
+ const i = rows.findIndex((r) => r[key] === k);
109
+ if (i >= 0) rows.splice(i, 1);
110
+ return Promise.resolve();
111
+ },
112
+ };
113
+ };
114
+ const app = {
115
+ data: { table },
116
+ engine: {
117
+ createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
118
+ started.push(req);
119
+ return Promise.resolve({ processInstanceKey: "PI-1" });
120
+ },
121
+ },
122
+ log: noopLog(),
123
+ } as any as AppApi;
124
+ return { app, started };
125
+ }
126
+
127
+ function input(body: unknown) {
128
+ return {
129
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
130
+ params: {},
131
+ query: {},
132
+ body,
133
+ };
134
+ }
135
+
136
+ function freshGithub(repo: string, extraBranches: string[] = []): GithubState {
137
+ return { repo, defaultBranch: "main", branches: new Set(["main", ...extraBranches]), creates: [] };
138
+ }
139
+
140
+ // ── Rule 1 — required + explicit ──────────────────────────────────────────────
141
+
142
+ // Rule 1 rejects both a MISSING baseBranch field and an explicit blank/whitespace
143
+ // one — the latter is a distinct edge input that must not slip past as a "present"
144
+ // value. Table-drive both so the required-and-explicit rule is covered end to end.
145
+ for (const [label, body] of [
146
+ ["missing field", { issue: "owner/repo#1" }],
147
+ ["empty string", { issue: "owner/repo#1", baseBranch: "" }],
148
+ ["whitespace only", { issue: "owner/repo#1", baseBranch: " " }],
149
+ ] as const) {
150
+ test(`edge: ${label} baseBranch → 400`, async () => {
151
+ const gh = freshGithub("owner/repo");
152
+ await withGithub(gh, async () => {
153
+ const { app, started } = makeApp();
154
+ const res = (await startPlanFanout(input(body), app)) as any;
155
+ assertEquals(res.status, 400);
156
+ assertEquals(typeof res.body.error, "string");
157
+ assertEquals(started.length, 0); // rejected before any fan-out
158
+ assertEquals(gh.creates, []); // no ref created on a rejected input
159
+ });
160
+ });
161
+ }
162
+
163
+ // ── Rule 2 — create-if-missing (epic/* guard), synchronously at the edge ──────
164
+
165
+ test("edge: non-epic/* base that does not exist → 400 (BaseBranchMustExistError path)", async () => {
166
+ // A typo'd, non-epic/* base is NOT auto-created — admitPlan throws BaseBranchMustExistError
167
+ // synchronously, which the delegate maps to a clean 400 at the door (not a late per-task failure).
168
+ const gh = freshGithub("owner/repo"); // "release-9" absent, not epic/* → must-exist
169
+ await withGithub(gh, async () => {
170
+ const { app, started } = makeApp();
171
+ const res = (await startPlanFanout(input({ issue: "owner/repo#2", baseBranch: "release-9" }), app)) as any;
172
+ assertEquals(res.status, 400);
173
+ assertEquals(typeof res.body.error, "string");
174
+ assertEquals(started.length, 0);
175
+ assertEquals(gh.creates, []); // never created
176
+ });
177
+ });
178
+
179
+ test("edge: missing epic/* base → created off default HEAD, then 202", async () => {
180
+ const gh = freshGithub("owner/repo"); // epic/new absent → auto-created off main
181
+ await withGithub(gh, async () => {
182
+ const { app, started } = makeApp();
183
+ const res = (await startPlanFanout(input({ issue: "owner/repo#3", baseBranch: "epic/new" }), app)) as any;
184
+ assertEquals(res.status, 202);
185
+ // Created off the resolved base sha: the ref-create payload names epic/new AND points it at the
186
+ // default branch's head sha (`main-sha` here), proving the create body carries the base sha.
187
+ assertEquals(gh.creates, [{ ref: "refs/heads/epic/new", sha: "main-sha" }]);
188
+ assertEquals(started.length, 1);
189
+ });
190
+ });
191
+
192
+ // ── Rule 3 — confirm-default ──────────────────────────────────────────────────
193
+
194
+ test("edge: target == default branch WITHOUT confirmDefaultBase → 400", async () => {
195
+ const gh = freshGithub("owner/repo"); // base "main" IS the default
196
+ await withGithub(gh, async () => {
197
+ const { app, started } = makeApp();
198
+ const res = (await startPlanFanout(input({ issue: "owner/repo#4", baseBranch: "main" }), app)) as any;
199
+ assertEquals(res.status, 400);
200
+ assertEquals(typeof res.body.error, "string");
201
+ assertEquals(started.length, 0);
202
+ });
203
+ });
204
+
205
+ test("edge: target == default branch WITH confirmDefaultBase → 202", async () => {
206
+ const gh = freshGithub("owner/repo");
207
+ await withGithub(gh, async () => {
208
+ const { app, started } = makeApp();
209
+ const res = (await startPlanFanout(
210
+ input({ issue: "owner/repo#5", baseBranch: "main", confirmDefaultBase: true }),
211
+ app,
212
+ )) as any;
213
+ assertEquals(res.status, 202);
214
+ assertEquals(started.length, 1);
215
+ });
216
+ });
217
+
218
+ // ── Rule 4 — shared-base guard ────────────────────────────────────────────────
219
+
220
+ test("edge: active plan on the same CUSTOM base WITHOUT allowSharedBase → 409", async () => {
221
+ const gh = freshGithub("owner/repo", ["epic/shared"]); // base exists → ensureBaseBranch no-ops
222
+ await withGithub(gh, async () => {
223
+ // A DIFFERENT active plan already targets epic/shared on this repo.
224
+ const { app, started } = makeApp([
225
+ { plan_key: "owner/repo#98", repo: "owner/repo", base_branch: "epic/shared", status: "planning" },
226
+ ]);
227
+ const res = (await startPlanFanout(input({ issue: "owner/repo#6", baseBranch: "epic/shared" }), app)) as any;
228
+ assertEquals(res.status, 409);
229
+ assertEquals(typeof res.body.error, "string");
230
+ assertEquals(started.length, 0);
231
+ });
232
+ });
233
+
234
+ test("edge: same CUSTOM base WITH allowSharedBase → 202", async () => {
235
+ const gh = freshGithub("owner/repo", ["epic/shared"]);
236
+ await withGithub(gh, async () => {
237
+ const { app, started } = makeApp([
238
+ { plan_key: "owner/repo#98", repo: "owner/repo", base_branch: "epic/shared", status: "planning" },
239
+ ]);
240
+ const res = (await startPlanFanout(
241
+ input({ issue: "owner/repo#7", baseBranch: "epic/shared", allowSharedBase: true }),
242
+ app,
243
+ )) as any;
244
+ assertEquals(res.status, 202);
245
+ assertEquals(started.length, 1);
246
+ });
247
+ });
248
+
249
+ test("edge: two plans sharing the DEFAULT branch → 202 (default is exempt from the shared-base guard)", async () => {
250
+ const gh = freshGithub("owner/repo"); // base "main" == default → exempt
251
+ await withGithub(gh, async () => {
252
+ // An active plan already targets main; a second one is still admitted (confirmed default).
253
+ const { app, started } = makeApp([
254
+ { plan_key: "owner/repo#97", repo: "owner/repo", base_branch: "main", status: "planning" },
255
+ ]);
256
+ const res = (await startPlanFanout(
257
+ input({ issue: "owner/repo#8", baseBranch: "main", confirmDefaultBase: true }),
258
+ app,
259
+ )) as any;
260
+ assertEquals(res.status, 202);
261
+ assertEquals(started.length, 1);
262
+ });
263
+ });