@nanobpm/nano-workforce 0.88.0 → 0.89.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,17 @@
1
+ # [0.89.0](https://github.com/nanobpm/nano-workforce/compare/v0.88.1...v0.89.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * auto-open the epic integration-branch → default-branch promotion PR on landing ([#299](https://github.com/nanobpm/nano-workforce/issues/299)) ([#300](https://github.com/nanobpm/nano-workforce/issues/300)) ([24a8854](https://github.com/nanobpm/nano-workforce/commit/24a885484ae69bd654edd4d05fa198561e2db5dd))
7
+
8
+ ## [0.88.1](https://github.com/nanobpm/nano-workforce/compare/v0.88.0...v0.88.1) (2026-08-18)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * scope user-task pollers to open tasks so they can't latch a COMPLETED task ([#297](https://github.com/nanobpm/nano-workforce/issues/297)) ([131a796](https://github.com/nanobpm/nano-workforce/commit/131a796dfaed8f9b2eb25526f6583b86b8d7b0df)), closes [#294](https://github.com/nanobpm/nano-workforce/issues/294)
14
+
1
15
  # [0.88.0](https://github.com/nanobpm/nano-workforce/compare/v0.87.0...v0.88.0) (2026-08-18)
2
16
 
3
17
 
package/app/contracts.ts CHANGED
@@ -354,6 +354,14 @@ export const TYPE_CONTRACTS = {
354
354
  "The snake_case, agent-facing view of a blackboard entry — the HTTP-hook boundary shape every caller and agent consumes. Both the read and write halves import this ONE definition.",
355
355
  module: "app/blackboard.ts",
356
356
  },
357
+ PlanDep: {
358
+ category: "type",
359
+ name: "PlanDep",
360
+ owner: "app/plan.ts",
361
+ semantics:
362
+ "One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. Set admission (S2), planner lowering (S3), and operator visibility (S4) all import this ONE row shape from app/plan.ts — no re-declared synonym.",
363
+ module: "app/plan.ts",
364
+ },
357
365
  } as const satisfies Record<string, TypeContract>;
358
366
 
359
367
  export const CAPABILITY_URL_CONTRACTS = {
package/app/feature.ts CHANGED
@@ -181,10 +181,12 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
181
181
  export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
182
182
 
183
183
  /** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
184
- * `searchUserTasks`: the completable user-task key the pages drive an attributed answer against.
184
+ * `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
185
+ * drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which
186
+ * holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task.
185
187
  *
186
188
  * The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
187
- * task's `zeebe:ioMapping`-mapped local variables through `searchUserTasks`, so relying on it would
189
+ * task's `zeebe:ioMapping`-mapped local variables through the user-task query, so relying on it would
188
190
  * make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
189
191
  * persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
190
192
  export interface FeatureEscalationParked {
@@ -240,8 +242,10 @@ export function deriveFeatureEscalationPatch(
240
242
  * `pollFeatureBlocked` reconciles it onto the read model. */
241
243
  export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
242
244
 
243
- /** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `searchUserTasks`:
244
- * the completable user-task key the pages drive an attributed acknowledgement against. */
245
+ /** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `openUserTasks`
246
+ * (the open-task-scoped query — issue #294): the completable user-task key the pages drive an
247
+ * attributed acknowledgement against. Scoping to `state:"CREATED"` keeps a re-blocked run — which
248
+ * holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task. */
245
249
  export interface FeatureBlockedParked {
246
250
  userTaskKey: string;
247
251
  }
@@ -49,12 +49,22 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
49
49
  return { data, stores };
50
50
  }
51
51
 
52
- /** A fake engine whose open user tasks are keyed by processInstanceKey (the only field
53
- * pollFeatureBlocked queries on). */
54
- function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
52
+ /** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
53
+ * defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
54
+ * A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
55
+ type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
56
+
57
+ /** A fake engine whose user tasks are keyed by processInstanceKey (the only field
58
+ * pollFeatureBlocked queries on). It models the real engine's two accessors from ONE fixture so a test
59
+ * genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
60
+ * (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
61
+ function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
62
+ const all = (filter?: { processInstanceKey?: string }) =>
63
+ filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
55
64
  return {
56
- searchUserTasks: (filter?: { processInstanceKey?: string }) =>
57
- Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
65
+ searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
66
+ openUserTasks: (filter?: { processInstanceKey?: string }) =>
67
+ Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
58
68
  } as unknown as EngineClient;
59
69
  }
60
70
 
@@ -133,3 +143,40 @@ test("pollFeatureBlocked: a parked non-blocked task (feature-escalation) does no
133
143
 
134
144
  assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
135
145
  });
146
+
147
+ // ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-blocked tasks ────────────
148
+ // The blocked wait sits on a blocked→ack loop, so a re-blocked run holds a COMPLETED feature-blocked
149
+ // task from a prior round alongside the live CREATED one; the engine returns the COMPLETED task first.
150
+ // Scoping the query to open (CREATED) tasks records the live key, never the terminal one.
151
+ test("pollFeatureBlocked: a looping run records the CREATED task, never the COMPLETED one", async () => {
152
+ const { data, stores } = memData();
153
+ stores.feature_runs = [
154
+ { feature_key: "o/r#6", status: "awaiting_operator", process_key: "600", blocked_user_task_key: null },
155
+ ];
156
+ const engine = fakeEngine({
157
+ "600": [
158
+ { userTaskKey: "ut-completed", elementId: "feature-blocked", state: "COMPLETED" },
159
+ { userTaskKey: "ut-live", elementId: "feature-blocked", state: "CREATED" },
160
+ ],
161
+ });
162
+
163
+ await pollFeatureBlocked(data, engine);
164
+
165
+ assertEquals(stores.feature_runs[0].blocked_user_task_key, "ut-live");
166
+ });
167
+
168
+ // Self-heal reached: a run past the blocked wait whose only feature-blocked task is COMPLETED must
169
+ // clear the stale pointer (open-task query returns [] → `parked=null`), not latch it on the dead key.
170
+ test("pollFeatureBlocked: a run whose only feature-blocked task is COMPLETED clears the stale pointer", async () => {
171
+ const { data, stores } = memData();
172
+ stores.feature_runs = [
173
+ { feature_key: "o/r#7", status: "awaiting_operator", process_key: "700", blocked_user_task_key: "ut-7" },
174
+ ];
175
+ const engine = fakeEngine({
176
+ "700": [{ userTaskKey: "ut-7", elementId: "feature-blocked", state: "COMPLETED" }],
177
+ });
178
+
179
+ await pollFeatureBlocked(data, engine);
180
+
181
+ assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
182
+ });
@@ -50,12 +50,22 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
50
50
  return { data, stores };
51
51
  }
52
52
 
53
- /** A fake engine whose open user tasks are keyed by processInstanceKey (the only field
54
- * pollFeatureEscalations queries on). */
55
- function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
53
+ /** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
54
+ * defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
55
+ * A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
56
+ type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
57
+
58
+ /** A fake engine whose user tasks are keyed by processInstanceKey (the only field
59
+ * pollFeatureEscalations queries on). It models the real engine's two accessors from ONE fixture so a
60
+ * test genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
61
+ * (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
62
+ function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
63
+ const all = (filter?: { processInstanceKey?: string }) =>
64
+ filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
56
65
  return {
57
- searchUserTasks: (filter?: { processInstanceKey?: string }) =>
58
- Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
66
+ searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
67
+ openUserTasks: (filter?: { processInstanceKey?: string }) =>
68
+ Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
59
69
  } as unknown as EngineClient;
60
70
  }
61
71
 
@@ -178,3 +188,48 @@ test("pollFeatureEscalations: a parked non-escalation task (feature-blocked) doe
178
188
  assertEquals(stores.feature_runs[0].status, "running");
179
189
  assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
180
190
  });
191
+
192
+ // ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-escalation tasks ─────────
193
+ // A run in an escalate→answer→implement→re-escalate loop holds a COMPLETED feature-escalation task
194
+ // from a prior round alongside the live CREATED one. The engine returns the COMPLETED task first, so a
195
+ // poller that reads UNFILTERED tasks and `.find`s by element latches onto the terminal key — pinning
196
+ // the pointer at a dead task. Scoping the query to open (CREATED) tasks resolves the live one.
197
+ test("pollFeatureEscalations: a looping run resolves the CREATED task, never the COMPLETED one", async () => {
198
+ const { data, stores } = memData();
199
+ stores.feature_runs = [
200
+ { feature_key: "o/r#7", status: "running", process_key: "700", escalation_question: null, escalation_user_task_key: null },
201
+ ];
202
+ // Engine returns the COMPLETED (prior-round) task FIRST, then the live CREATED one (issue #294).
203
+ const engine = fakeEngine({
204
+ "700": [
205
+ { userTaskKey: "ut-completed", elementId: "feature-escalation", state: "COMPLETED" },
206
+ { userTaskKey: "ut-live", elementId: "feature-escalation", state: "CREATED" },
207
+ ],
208
+ });
209
+
210
+ await pollFeatureEscalations(data, engine);
211
+
212
+ assertEquals(stores.feature_runs[0].status, "escalated");
213
+ // Must be the live CREATED task, not the COMPLETED prior-round one.
214
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-live");
215
+ });
216
+
217
+ // Self-heal reached: once a run finally exits escalation for good, only a lingering COMPLETED
218
+ // feature-escalation task remains. The open-task query returns [], so `parked=null` and the run
219
+ // resets escalated→running — instead of a false-positive pointer built from the terminal task pinning
220
+ // `status='escalated'` forever.
221
+ test("pollFeatureEscalations: a run whose only feature-escalation task is COMPLETED self-heals to running", async () => {
222
+ const { data, stores } = memData();
223
+ stores.feature_runs = [
224
+ { feature_key: "o/r#8", status: "escalated", process_key: "800", escalation_question: "Q", escalation_user_task_key: "ut-8" },
225
+ ];
226
+ const engine = fakeEngine({
227
+ "800": [{ userTaskKey: "ut-8", elementId: "feature-escalation", state: "COMPLETED" }],
228
+ });
229
+
230
+ await pollFeatureEscalations(data, engine);
231
+
232
+ assertEquals(stores.feature_runs[0].status, "running");
233
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
234
+ assertEquals(stores.feature_runs[0].escalation_question, null);
235
+ });
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, coalesceTitle, ensureBaseBranch, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
6
+ import { BaseBranchMustExistError, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -335,3 +335,96 @@ test("coalesceTitle: skips a blank middle candidate to the next non-blank one",
335
335
  assertEquals(coalesceTitle("", "Prior title", "owner/repo#1"), "Prior title");
336
336
  assertEquals(coalesceTitle(null, " ", "owner/repo#1"), "owner/repo#1");
337
337
  });
338
+
339
+ // ── Epic promotion PR helpers (issue #299) ──────────────────────────────────
340
+ // The promotion pass opens exactly one `epic/* → <default>` PR per landed epic. These unit tests
341
+ // pin the GitHub token-transport primitives it relies on: reading PRs by head branch (idempotency
342
+ // reconciliation), creating a PR, and the `ensurePromotionPr` reuse-vs-create decision.
343
+ interface FakePulls {
344
+ repo: string;
345
+ // head branch → list of PRs opened from it
346
+ byHead: Map<string, { number: number; state: string; baseRef: string }[]>;
347
+ creates: { head: string; base: string; number: number }[];
348
+ next: number;
349
+ }
350
+
351
+ function pullsFetch(state: FakePulls) {
352
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
353
+ const u = new URL(String(url));
354
+ const method = (init?.method ?? "GET").toUpperCase();
355
+ const path = u.pathname;
356
+ if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
357
+ const head = (u.searchParams.get("head") ?? "").split(":").pop() ?? "";
358
+ const list = state.byHead.get(head) ?? [];
359
+ return Promise.resolve(
360
+ jsonResponse(
361
+ list.map((p) => ({
362
+ number: p.number,
363
+ html_url: `https://github.com/${state.repo}/pull/${p.number}`,
364
+ state: p.state,
365
+ base: { ref: p.baseRef },
366
+ })),
367
+ ),
368
+ );
369
+ }
370
+ if (method === "POST" && path === `/repos/${state.repo}/pulls`) {
371
+ // biome-ignore lint/plugin: test fixture parsing an external body shape
372
+ const body = JSON.parse(String(init?.body ?? "{}")) as { head?: string; base?: string };
373
+ const head = String(body.head ?? "");
374
+ const base = String(body.base ?? "");
375
+ const number = state.next++;
376
+ state.creates.push({ head, base, number });
377
+ const arr = state.byHead.get(head) ?? [];
378
+ arr.push({ number, state: "open", baseRef: base });
379
+ state.byHead.set(head, arr);
380
+ return Promise.resolve(jsonResponse({ number, html_url: `https://github.com/${state.repo}/pull/${number}` }, 201));
381
+ }
382
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
383
+ };
384
+ }
385
+
386
+ async function withPulls<T>(state: FakePulls, fn: () => Promise<T>): Promise<T> {
387
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
388
+ const prevFetch = globalThis.fetch;
389
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
390
+ globalThis.fetch = pullsFetch(state) as typeof fetch;
391
+ try {
392
+ return await fn();
393
+ } finally {
394
+ globalThis.fetch = prevFetch;
395
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
396
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
397
+ }
398
+ }
399
+
400
+ function freshPulls(): FakePulls {
401
+ return { repo: "o/r", byHead: new Map(), creates: [], next: 500 };
402
+ }
403
+
404
+ test("listPrsForHead: returns the PRs opened from a head branch", async () => {
405
+ const state = freshPulls();
406
+ state.byHead.set("epic/x", [{ number: 12, state: "open", baseRef: "main" }]);
407
+ const list = await withPulls(state, () => listPrsForHead("o/r", "epic/x", "tok"));
408
+ assertEquals(list?.length, 1);
409
+ assertEquals(list?.[0].number, 12);
410
+ assertEquals(list?.[0].baseRef, "main");
411
+ });
412
+
413
+ test("createPullRequest: opens a PR and returns its number + url", async () => {
414
+ const state = freshPulls();
415
+ const pr = await withPulls(state, () => createPullRequest("o/r", "epic/x", "main", "T", "B", "tok"));
416
+ assertEquals(pr?.number, 500);
417
+ assertEquals(state.creates.length, 1);
418
+ assertEquals(state.creates[0].base, "main");
419
+ });
420
+
421
+ test("ensurePromotionPr: creates when none exists, then reuses on a re-run (idempotent)", async () => {
422
+ const state = freshPulls();
423
+ const first = await withPulls(state, () => ensurePromotionPr("o/r", "epic/x", "main", "T", "B", "tok"));
424
+ assertEquals(first?.created, true);
425
+ assertEquals(first?.number, 500);
426
+ const second = await withPulls(state, () => ensurePromotionPr("o/r", "epic/x", "main", "T", "B", "tok"));
427
+ assertEquals(second?.created, false);
428
+ assertEquals(second?.number, 500);
429
+ assertEquals(state.creates.length, 1);
430
+ });
package/app/github.ts CHANGED
@@ -1091,3 +1091,153 @@ export async function ensureBaseBranch(
1091
1091
  const created = await createBranchRef(repo, branch, defaultSha, token);
1092
1092
  return created ? "created" : "exists";
1093
1093
  }
1094
+
1095
+ // ── Epic promotion PR (issue #299) ──────────────────────────────────────────
1096
+ // Once an epic's slices have all merged into its `epic/*` integration branch, the poller opens a
1097
+ // single `epic/* → <default>` promotion PR to deliver the epic. These helpers are the GitHub side
1098
+ // of that: discover an already-open promotion PR (idempotency against a crash between create and
1099
+ // the DB write) and, when none exists, create it.
1100
+
1101
+ /** A pull request discovered for a head branch — the subset the promotion idempotency check reads. */
1102
+ export interface HeadPr {
1103
+ number: number;
1104
+ url: string;
1105
+ state: string;
1106
+ baseRef: string | null;
1107
+ }
1108
+
1109
+ /** List the PRs (any state) whose HEAD branch is `headBranch` on `repo`. Used to reconcile the
1110
+ * promotion PR idempotently: an `epic/*` integration branch is only ever the HEAD of its promotion
1111
+ * PR (slices target it as their BASE), so any result is that promotion PR. Returns `null` when no
1112
+ * transport is usable (idle — the caller retries next pass). */
1113
+ export async function listPrsForHead(
1114
+ repo: string,
1115
+ headBranch: string,
1116
+ token: string,
1117
+ ): Promise<HeadPr[] | null> {
1118
+ if (await useGh()) {
1119
+ const out = await runGh([
1120
+ "pr",
1121
+ "list",
1122
+ "--repo",
1123
+ repo,
1124
+ "--head",
1125
+ headBranch,
1126
+ "--state",
1127
+ "all",
1128
+ "--json",
1129
+ "number,url,state,baseRefName",
1130
+ "--limit",
1131
+ "20",
1132
+ ]);
1133
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1134
+ const arr = JSON.parse(out) as { number?: number; url?: string; state?: string; baseRefName?: string | null }[];
1135
+ return arr.map((p) => ({
1136
+ number: Number(p.number),
1137
+ url: p.url ?? "",
1138
+ state: (p.state ?? "").toLowerCase(),
1139
+ baseRef: p.baseRefName ?? null,
1140
+ }));
1141
+ }
1142
+ if (!token) return null;
1143
+ const owner = repo.split("/")[0];
1144
+ const r = await fetch(
1145
+ `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${headBranch}`)}&per_page=20`,
1146
+ { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
1147
+ );
1148
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
1149
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1150
+ const arr = (await r.json()) as { number?: number; html_url?: string; state?: string; base?: { ref?: string | null } }[];
1151
+ return arr.map((p) => ({
1152
+ number: Number(p.number),
1153
+ url: p.html_url ?? "",
1154
+ state: (p.state ?? "").toLowerCase(),
1155
+ baseRef: p.base?.ref ?? null,
1156
+ }));
1157
+ }
1158
+
1159
+ /** The identity of a freshly-created (or reused) PR. */
1160
+ export interface CreatedPr {
1161
+ number: number;
1162
+ url: string;
1163
+ }
1164
+
1165
+ /** Open a pull request from `headBranch` into `baseBranch` on `repo`. Returns the new PR's
1166
+ * number + URL, or `null` when no transport is usable (idle — the caller retries next pass). Throws
1167
+ * on a genuine create failure so the caller logs and retries rather than silently losing the PR. */
1168
+ export async function createPullRequest(
1169
+ repo: string,
1170
+ headBranch: string,
1171
+ baseBranch: string,
1172
+ title: string,
1173
+ body: string,
1174
+ token: string,
1175
+ ): Promise<CreatedPr | null> {
1176
+ if (await useGh()) {
1177
+ const out = await runGh([
1178
+ "pr",
1179
+ "create",
1180
+ "--repo",
1181
+ repo,
1182
+ "--base",
1183
+ baseBranch,
1184
+ "--head",
1185
+ headBranch,
1186
+ "--title",
1187
+ title,
1188
+ "--body",
1189
+ body,
1190
+ ]);
1191
+ // `gh pr create` prints the new PR's URL on stdout; parse its number from the canonical path.
1192
+ const url = out.trim().split(/\s+/).pop() ?? "";
1193
+ const m = url.match(/\/pull\/(\d+)/);
1194
+ if (!m) throw new Error(`could not parse a PR number from \`gh pr create\` output: ${out.trim()}`);
1195
+ return { number: Number(m[1]), url };
1196
+ }
1197
+ if (!token) return null;
1198
+ const r = await fetch(`https://api.github.com/repos/${repo}/pulls`, {
1199
+ method: "POST",
1200
+ headers: {
1201
+ authorization: `Bearer ${token}`,
1202
+ accept: "application/vnd.github+json",
1203
+ "content-type": "application/json",
1204
+ },
1205
+ body: JSON.stringify({ title, head: headBranch, base: baseBranch, body }),
1206
+ });
1207
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
1208
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1209
+ const j = (await r.json()) as { number?: number; html_url?: string };
1210
+ return { number: Number(j.number), url: j.html_url ?? "" };
1211
+ }
1212
+
1213
+ /** The outcome of `ensurePromotionPr`: the promotion PR's number + URL and whether THIS call
1214
+ * created it (`created: false` ⇒ an existing one was reused, keeping the open idempotent). */
1215
+ export interface EnsurePromotionPrResult extends CreatedPr {
1216
+ created: boolean;
1217
+ }
1218
+
1219
+ /** Idempotently guarantee the `headBranch → baseBranch` promotion PR exists on `repo`. First
1220
+ * reconciles against GitHub — an `epic/*` integration branch is only ever the HEAD of its own
1221
+ * promotion PR, so ANY open/merged PR from it IS that promotion PR and is reused (this closes the
1222
+ * window where a crash between GitHub-create and the DB write would otherwise duplicate the PR).
1223
+ * Only when none exists is a new one created. Returns `null` when no transport is usable. */
1224
+ export async function ensurePromotionPr(
1225
+ repo: string,
1226
+ headBranch: string,
1227
+ baseBranch: string,
1228
+ title: string,
1229
+ body: string,
1230
+ token: string,
1231
+ ): Promise<EnsurePromotionPrResult | null> {
1232
+ const existing = await listPrsForHead(repo, headBranch, token);
1233
+ if (existing === null) return null; // no transport → retry next pass
1234
+ // Prefer a PR that already targets the intended base; otherwise reuse any PR from this branch
1235
+ // (the head is unique to the promotion PR, so this can only be it).
1236
+ const reuse = existing.find((p) => p.baseRef === baseBranch) ?? existing[0];
1237
+ if (reuse && Number.isFinite(reuse.number) && reuse.number > 0) {
1238
+ return { number: reuse.number, url: reuse.url, created: false };
1239
+ }
1240
+ const created = await createPullRequest(repo, headBranch, baseBranch, title, body, token);
1241
+ if (!created) return null;
1242
+ return { ...created, created: true };
1243
+ }
@@ -0,0 +1,79 @@
1
+ // Regression guard for migration 041 (issue #292 slice S1): the durable constraints on the INTER-epic
2
+ // `plan_deps` table. The app-layer accessors (app/planDeps.test.ts) enforce self-edge / duplicate
3
+ // rejection for the in-memory data layer; this test proves the SCHEMA itself is the backstop — the
4
+ // migration applies cleanly, a self-edge trips the CHECK, a duplicate consumer→producer pair trips
5
+ // the PRIMARY KEY, and the consumer `plan_key` foreign-keys to an admitted plan.
6
+ import { readFileSync } from "node:fs";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import test from "node:test";
9
+ import { fileURLToPath } from "node:url";
10
+ import { assertEquals } from "#test-assert";
11
+
12
+ function migratedDb(): DatabaseSync {
13
+ const db = new DatabaseSync(":memory:");
14
+ db.exec("PRAGMA foreign_keys = ON;");
15
+ // Minimal `plans` shape the migration's FK references.
16
+ db.exec("CREATE TABLE plans (plan_key TEXT PRIMARY KEY);");
17
+ for (const k of ["o/r#1", "o/r#2", "o/r#3"]) {
18
+ db.prepare("INSERT INTO plans (plan_key) VALUES (?)").run(k);
19
+ }
20
+ const sql = readFileSync(
21
+ fileURLToPath(new URL("../db/migrations/041_inter_epic_plan_deps.sql", import.meta.url)),
22
+ "utf8",
23
+ );
24
+ db.exec(sql);
25
+ return db;
26
+ }
27
+
28
+ const insert = (db: DatabaseSync, planKey: string, dependsOn: string) =>
29
+ db
30
+ .prepare(
31
+ `INSERT INTO plan_deps (plan_key, depends_on_plan_key, package, capability_ref, created_at)
32
+ VALUES (?, ?, '@nanobpm/p', ?, 't')`,
33
+ )
34
+ .run(planKey, dependsOn, dependsOn);
35
+
36
+ test("migration 041 applies cleanly and records a valid inter-epic edge", () => {
37
+ const db = migratedDb();
38
+ insert(db, "o/r#2", "o/r#1");
39
+ const row = db
40
+ .prepare("SELECT plan_key, depends_on_plan_key, package FROM plan_deps WHERE plan_key = ?")
41
+ .get("o/r#2") as { plan_key: string; depends_on_plan_key: string; package: string };
42
+ assertEquals(row.plan_key, "o/r#2");
43
+ assertEquals(row.depends_on_plan_key, "o/r#1");
44
+ assertEquals(row.package, "@nanobpm/p");
45
+ });
46
+
47
+ test("migration 041 CHECK rejects a self-edge", () => {
48
+ const db = migratedDb();
49
+ let threw = false;
50
+ try {
51
+ insert(db, "o/r#1", "o/r#1");
52
+ } catch {
53
+ threw = true;
54
+ }
55
+ assertEquals(threw, true);
56
+ });
57
+
58
+ test("migration 041 PRIMARY KEY rejects a duplicate consumer→producer edge", () => {
59
+ const db = migratedDb();
60
+ insert(db, "o/r#2", "o/r#1");
61
+ let threw = false;
62
+ try {
63
+ insert(db, "o/r#2", "o/r#1");
64
+ } catch {
65
+ threw = true;
66
+ }
67
+ assertEquals(threw, true);
68
+ });
69
+
70
+ test("migration 041 FK ties the consumer plan_key to an admitted plan", () => {
71
+ const db = migratedDb();
72
+ let threw = false;
73
+ try {
74
+ insert(db, "o/r#404", "o/r#1");
75
+ } catch {
76
+ threw = true;
77
+ }
78
+ assertEquals(threw, true);
79
+ });
@@ -0,0 +1,51 @@
1
+ // Regression guard for migration 042 (issue #299): the promotion columns on `plans`. Proves the
2
+ // migration applies cleanly onto the pre-#299 `plans` shape and that a landed epic can record its
3
+ // promotion PR + state.
4
+ import { readFileSync } from "node:fs";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { assertEquals } from "#test-assert";
9
+
10
+ function migratedDb(): DatabaseSync {
11
+ const db = new DatabaseSync(":memory:");
12
+ // Minimal pre-#299 `plans` shape the migration extends (only the columns this test touches).
13
+ db.exec(
14
+ "CREATE TABLE plans (plan_key TEXT PRIMARY KEY, base_branch TEXT, delivery TEXT, delivery_label TEXT);",
15
+ );
16
+ db.prepare("INSERT INTO plans (plan_key, base_branch, delivery) VALUES (?, ?, ?)").run(
17
+ "o/r#295",
18
+ "epic/test-dsl",
19
+ "landed",
20
+ );
21
+ const sql = readFileSync(
22
+ fileURLToPath(new URL("../db/migrations/042_plan_promotion.sql", import.meta.url)),
23
+ "utf8",
24
+ );
25
+ db.exec(sql);
26
+ return db;
27
+ }
28
+
29
+ test("migration 042 applies cleanly and adds nullable promotion columns", () => {
30
+ const db = migratedDb();
31
+ const row = db
32
+ .prepare("SELECT promotion_pr, promotion_state FROM plans WHERE plan_key = ?")
33
+ .get("o/r#295") as { promotion_pr: string | null; promotion_state: string | null };
34
+ // Grandfathered: both columns default to NULL on the existing row.
35
+ assertEquals(row.promotion_pr, null);
36
+ assertEquals(row.promotion_state, null);
37
+ });
38
+
39
+ test("migration 042 lets a landed epic record its promotion PR + state", () => {
40
+ const db = migratedDb();
41
+ db.prepare("UPDATE plans SET promotion_pr = ?, promotion_state = ? WHERE plan_key = ?").run(
42
+ "o/r#500",
43
+ "open",
44
+ "o/r#295",
45
+ );
46
+ const row = db
47
+ .prepare("SELECT promotion_pr, promotion_state FROM plans WHERE plan_key = ?")
48
+ .get("o/r#295") as { promotion_pr: string; promotion_state: string };
49
+ assertEquals(row.promotion_pr, "o/r#500");
50
+ assertEquals(row.promotion_state, "open");
51
+ });