@nanobpm/nano-workforce 0.88.1 → 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,10 @@
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
+
1
8
  ## [0.88.1](https://github.com/nanobpm/nano-workforce/compare/v0.88.0...v0.88.1) (2026-08-18)
2
9
 
3
10
 
@@ -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,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
+ });
package/app/plan.ts CHANGED
@@ -89,6 +89,17 @@ export interface Plan {
89
89
  // view can show which phase the epic is IN rather than only the process-instance terminal status.
90
90
  // Display-only; NULL until the lifecycle first stamps it (grandfathers pre-#261 rows).
91
91
  epic_phase: string | null;
92
+ // Epic integration-branch → default-branch promotion (042_plan_promotion.sql, #299). When an epic
93
+ // targets a custom `epic/*` integration branch and every slice PR has merged (`delivery = landed`),
94
+ // the poller's `pollPromotion` pass opens exactly ONE `epic/* → <default>` promotion PR and drives
95
+ // it through the same convergence + merge protocol as every other PR (see app/promotion.ts).
96
+ // • promotion_pr — the `owner/repo#N` key of that promotion PR, or NULL until one is opened.
97
+ // PRIMARY idempotency key: a set value never re-opens a second PR.
98
+ // • promotion_state — the epic-card progression 'ready' → 'open' → 'promoted', or NULL until the
99
+ // epic first becomes promotable (also NULL forever for a `main`-based epic,
100
+ // which has nothing to promote). Display-only; projected by the poller.
101
+ promotion_pr: string | null;
102
+ promotion_state: string | null;
92
103
  created_at: string;
93
104
  updated_at: string;
94
105
  }
@@ -0,0 +1,56 @@
1
+ // Pure derivation tests for epic promotion (issue #299). `app/promotion.ts` is the I/O-free core of
2
+ // the "promote a landed epic's integration branch to the default branch" automation: the promotable
3
+ // predicate, the epic-card state derivation, and the promotion PR title/body rendering. The poller
4
+ // (`pollPromotion`) is exercised separately in `app/promotionPoll.test.ts`.
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
8
+
9
+ test("isPromotable: landed on an epic/* base is promotable", () => {
10
+ assert(isPromotable({ delivery: "landed", base_branch: "epic/test-dsl" }));
11
+ });
12
+
13
+ test("isPromotable: a main-based epic has nothing to promote", () => {
14
+ assert(!isPromotable({ delivery: "landed", base_branch: "main" }));
15
+ assert(!isPromotable({ delivery: "landed", base_branch: null }));
16
+ });
17
+
18
+ test("isPromotable: a still-converging epic is never promoted, even on an epic/* base", () => {
19
+ assert(!isPromotable({ delivery: "converging", base_branch: "epic/x" }));
20
+ assert(!isPromotable({ delivery: null, base_branch: "epic/x" }));
21
+ });
22
+
23
+ test("isEpicIntegrationBranch: only epic/* branches match", () => {
24
+ assert(isEpicIntegrationBranch("epic/foo"));
25
+ assert(!isEpicIntegrationBranch("main"));
26
+ assert(!isEpicIntegrationBranch("feat/epic-ish"));
27
+ assert(!isEpicIntegrationBranch(null));
28
+ });
29
+
30
+ test("derivePromotionState: ready → open → promoted progression", () => {
31
+ assertEquals(derivePromotionState(false, false), "ready");
32
+ assertEquals(derivePromotionState(true, false), "open");
33
+ assertEquals(derivePromotionState(true, true), "promoted");
34
+ });
35
+
36
+ test("promotionPrTitle: names branch, target, and epic identity", () => {
37
+ assertEquals(
38
+ promotionPrTitle("epic/test-dsl", "main", "Assertion DSL"),
39
+ "Promote epic/test-dsl → main: Assertion DSL",
40
+ );
41
+ });
42
+
43
+ test("promotionPrBody: closes the epic issue and lists the merged slices", () => {
44
+ const body = promotionPrBody("epic/x", "main", "o/r#295", ["o/r#299", "o/r#304"]);
45
+ assert(body.includes("epic/x"));
46
+ assert(body.includes("main"));
47
+ assert(body.includes("Closes o/r#295"));
48
+ assert(body.includes("- o/r#299"));
49
+ assert(body.includes("- o/r#304"));
50
+ });
51
+
52
+ test("promotionPrBody: omits the slice list when there are none", () => {
53
+ const body = promotionPrBody("epic/x", "main", "o/r#295", []);
54
+ assert(body.includes("Closes o/r#295"));
55
+ assert(!body.includes("Merged slices:"));
56
+ });
@@ -0,0 +1,84 @@
1
+ // Epic promotion derivation (issue #299): the pure, I/O-free core of the "promote a landed epic's
2
+ // integration branch to the default branch" automation. Extracted from `service.ts` (mirroring how
3
+ // `deriveDelivery` lives in `delivery.ts`) so the promotion predicate + state derivation + PR
4
+ // title/body rendering are unit-testable without a data layer or GitHub transport.
5
+ //
6
+ // The gap this closes: when an epic targets a custom `epic/*` integration branch, its slices PR
7
+ // *into* that branch. Once every slice merges (`plans.delivery = landed`, projected by
8
+ // `pollDelivery`), the epic is delivered ON the integration branch — but nothing opens the final
9
+ // `epic/* → <default>` promotion PR. `pollPromotion` (app/service.ts) uses these helpers to open
10
+ // exactly one such PR per landed epic and drive it through the same convergence + merge protocol.
11
+
12
+ /** The epic-card promotion progression for a landed epic (issue #299 point 3), denormalised onto
13
+ * `plans.promotion_state`:
14
+ * • `ready` — landed on an `epic/*` base; the promotion PR has not been opened yet.
15
+ * • `open` — the promotion PR is open and converging toward merge.
16
+ * • `promoted` — the promotion PR merged; the epic is delivered on the default branch. */
17
+ export type PromotionState = "ready" | "open" | "promoted";
18
+
19
+ /** The subset of a plan the promotion derivation reads. */
20
+ export interface PromotablePlan {
21
+ /** The derived delivery signal (`deriveDelivery`): only a `landed` epic is ever promotable. */
22
+ delivery: string | null;
23
+ /** The epic's target integration branch, e.g. `epic/test-dsl`. NULL / non-`epic/*` ⇒ nothing to
24
+ * promote (a `main`-based epic's slices already landed on the default branch). */
25
+ base_branch: string | null;
26
+ }
27
+
28
+ /** Whether `branch` is an auto-created `epic/*` integration branch (mirrors github.ts's
29
+ * `isEpicBranch` — kept local so this module stays pure/dependency-free). */
30
+ export function isEpicIntegrationBranch(branch: string | null): branch is string {
31
+ return !!branch && branch.startsWith("epic/");
32
+ }
33
+
34
+ /** Whether an epic is eligible for auto-promotion: its fan-out has LANDED (every slice PR merged —
35
+ * the `deriveDelivery` `landed` predicate, which already encodes `prsInFlight == 0 && prsMerged ==
36
+ * prsOpened && prsOpened > 0`, so a still-converging epic is never promoted) AND it targets a custom
37
+ * `epic/*` integration branch. A `main`-based epic (slices went straight to the default branch) has
38
+ * nothing to promote. */
39
+ export function isPromotable(plan: PromotablePlan): boolean {
40
+ return plan.delivery === "landed" && isEpicIntegrationBranch(plan.base_branch);
41
+ }
42
+
43
+ /** Derive the promotion-state projection for a promotable epic from whether its promotion PR exists
44
+ * yet and whether that PR has merged. Pure; the poller writes the result onto `plans.promotion_state`.
45
+ * • no PR yet → `ready`
46
+ * • PR exists, unmerged → `open`
47
+ * • PR merged → `promoted` */
48
+ export function derivePromotionState(hasPr: boolean, prMerged: boolean): PromotionState {
49
+ if (prMerged) return "promoted";
50
+ if (hasPr) return "open";
51
+ return "ready";
52
+ }
53
+
54
+ /** The title for an epic's promotion PR: names the integration branch, the target it promotes into,
55
+ * and the epic's human identity. */
56
+ export function promotionPrTitle(base: string, target: string, epicTitle: string): string {
57
+ return `Promote ${base} → ${target}: ${epicTitle}`;
58
+ }
59
+
60
+ /** Render the promotion PR body: a short explanation, the parent epic issue (as `Closes` so the
61
+ * epic closes when the promotion lands — the epic is only truly delivered once its integration
62
+ * branch reaches the default branch), and the list of merged slice PRs it carries. `slicePrKeys`
63
+ * are `owner/repo#N` keys; a `Depends-on:` is deliberately NOT emitted — the slices have already
64
+ * merged into the integration branch, so the promotion PR has no live dependency. */
65
+ export function promotionPrBody(
66
+ base: string,
67
+ target: string,
68
+ issueRef: string,
69
+ slicePrKeys: readonly string[],
70
+ ): string {
71
+ const lines = [
72
+ `Automated promotion of the landed epic integration branch \`${base}\` into \`${target}\`.`,
73
+ "",
74
+ `Every slice of this epic has merged into \`${base}\`; this PR delivers the whole epic to ` +
75
+ `\`${target}\`. It converges and merges through the standard review + merge protocol.`,
76
+ "",
77
+ `Closes ${issueRef}`,
78
+ ];
79
+ if (slicePrKeys.length > 0) {
80
+ lines.push("", "Merged slices:");
81
+ for (const key of slicePrKeys) lines.push(`- ${key}`);
82
+ }
83
+ return `${lines.join("\n")}\n`;
84
+ }
@@ -0,0 +1,255 @@
1
+ // Integration tests for the epic promotion poller pass (issue #299). `pollPromotion` is the missing
2
+ // counterpart to `ensureBaseBranch`: once an epic has LANDED on its custom `epic/*` integration
3
+ // branch (every slice PR merged → `plans.delivery = landed`), it opens exactly ONE `epic/* →
4
+ // <default>` promotion PR and enrolls it into the convergence + merge loop. These tests exercise the
5
+ // issue's red/green plan against an in-memory data layer + a stubbed GitHub (token) transport + a
6
+ // recording engine: open exactly one PR, never a duplicate on re-run, never for a converging epic,
7
+ // and never for a `main`-based epic.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
+ import { resetDefaultBranchCache } from "./github.ts";
12
+ import { pollPromotion } from "./service.ts";
13
+
14
+ // In-memory record gateway (all/get/find/insert/update/delete), mirroring app/delivery.test.ts but
15
+ // with `delete` (submitPr's `registerDependencies` clears the PR's dep set on submit).
16
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
17
+ const stores: Record<string, any[]> = {};
18
+ function tbl(name: string, pk = "id") {
19
+ const rows = (stores[name] ??= [] as any[]);
20
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
21
+ return {
22
+ async all() {
23
+ return rows.slice();
24
+ },
25
+ async get(id: any) {
26
+ return rows.find((r) => r[pk] === id);
27
+ },
28
+ async find(where: any = {}) {
29
+ return rows.filter((r) => match(r, where));
30
+ },
31
+ async insert(row: any) {
32
+ rows.push({ ...row });
33
+ return row[pk];
34
+ },
35
+ async update(id: any, patch: any) {
36
+ const r = rows.find((row) => row[pk] === id);
37
+ if (r) Object.assign(r, patch);
38
+ },
39
+ async delete(id: any) {
40
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
41
+ },
42
+ };
43
+ }
44
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
+ return { data, stores };
46
+ }
47
+
48
+ // A recording engine stub: every `submitPr` starts a convergence instance via `createInstance`.
49
+ function recordingEngine(): { engine: EngineClient; instances: any[] } {
50
+ const instances: any[] = [];
51
+ const engine = {
52
+ async createInstance(req: any) {
53
+ instances.push(req);
54
+ return { processInstanceKey: `pi-${instances.length}` };
55
+ },
56
+ } as any as EngineClient;
57
+ return { engine, instances };
58
+ }
59
+
60
+ // A fake GitHub repo model served over the token transport. Tracks the default branch and the PRs
61
+ // keyed by head branch; records every create so a test can assert exactly-once.
62
+ interface FakeRepo {
63
+ repo: string;
64
+ defaultBranch: string;
65
+ prsByHead: Map<string, { number: number; state: string; baseRef: string }[]>;
66
+ creates: { head: string; base: string; title: string; number: number }[];
67
+ nextNumber: number;
68
+ }
69
+
70
+ function githubFetch(state: FakeRepo) {
71
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
72
+ const u = new URL(String(url));
73
+ const method = (init?.method ?? "GET").toUpperCase();
74
+ const path = u.pathname;
75
+ const json = (obj: unknown, status = 200) =>
76
+ Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
77
+
78
+ if (method === "GET" && path === `/repos/${state.repo}`) {
79
+ return json({ default_branch: state.defaultBranch });
80
+ }
81
+ if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
82
+ // listPrsForHead: ?head=owner:branch
83
+ const head = (u.searchParams.get("head") ?? "").split(":").pop() ?? "";
84
+ const list = state.prsByHead.get(head) ?? [];
85
+ return json(
86
+ list.map((p) => ({
87
+ number: p.number,
88
+ html_url: `https://github.com/${state.repo}/pull/${p.number}`,
89
+ state: p.state,
90
+ base: { ref: p.baseRef },
91
+ })),
92
+ );
93
+ }
94
+ if (method === "POST" && path === `/repos/${state.repo}/pulls`) {
95
+ // biome-ignore lint/plugin: test fixture parsing an external body shape
96
+ const body = JSON.parse(String(init?.body ?? "{}")) as { head?: string; base?: string; title?: string };
97
+ const head = String(body.head ?? "");
98
+ const number = state.nextNumber++;
99
+ state.creates.push({ head, base: String(body.base ?? ""), title: String(body.title ?? ""), number });
100
+ const arr = state.prsByHead.get(head) ?? [];
101
+ arr.push({ number, state: "open", baseRef: String(body.base ?? "") });
102
+ state.prsByHead.set(head, arr);
103
+ return json({ number, html_url: `https://github.com/${state.repo}/pull/${number}` }, 201);
104
+ }
105
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
106
+ };
107
+ }
108
+
109
+ async function withGithub<T>(state: FakeRepo, fn: () => Promise<T>): Promise<T> {
110
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
111
+ const prevFetch = globalThis.fetch;
112
+ const prevToken = process.env.GITHUB_TOKEN;
113
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
114
+ // Leave GITHUB_TOKEN empty so submitPr's best-effort PR-meta enrichment is skipped (no fetch),
115
+ // while pollPromotion's own GitHub calls use the explicit "tok" argument.
116
+ delete process.env.GITHUB_TOKEN;
117
+ globalThis.fetch = githubFetch(state) as typeof fetch;
118
+ resetDefaultBranchCache();
119
+ try {
120
+ return await fn();
121
+ } finally {
122
+ globalThis.fetch = prevFetch;
123
+ resetDefaultBranchCache();
124
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
125
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
126
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
127
+ else process.env.GITHUB_TOKEN = prevToken;
128
+ }
129
+ }
130
+
131
+ function freshRepo(defaultBranch = "main"): FakeRepo {
132
+ return { repo: "o/r", defaultBranch, prsByHead: new Map(), creates: [], nextNumber: 500 };
133
+ }
134
+
135
+ test("pollPromotion: a landed epic on an epic/* base opens exactly one epic/*→default PR", async () => {
136
+ const { data, stores } = memData();
137
+ const { engine, instances } = recordingEngine();
138
+ stores.plans = [
139
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null },
140
+ ];
141
+ stores.plan_tasks = [
142
+ { id: 1, plan_key: "o/r#295", pr_key: "o/r#299" },
143
+ { id: 2, plan_key: "o/r#295", pr_key: "o/r#304" },
144
+ ];
145
+ stores.pull_requests = [
146
+ { pr_key: "o/r#299", status: "merged" },
147
+ { pr_key: "o/r#304", status: "merged" },
148
+ ];
149
+ const state = freshRepo();
150
+
151
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
152
+
153
+ assertEquals(state.creates.length, 1);
154
+ assertEquals(state.creates[0].head, "epic/test-dsl");
155
+ assertEquals(state.creates[0].base, "main");
156
+ assertEquals(stores.plans[0].promotion_pr, "o/r#500");
157
+ assertEquals(stores.plans[0].promotion_state, "open");
158
+ // The promotion PR was enrolled into the convergence loop (a real PR, not an auto-merge).
159
+ assertEquals(instances.length, 1);
160
+ assertEquals(instances[0].variables.prKey, "o/r#500");
161
+ const prRow = stores.pull_requests.find((p) => p.pr_key === "o/r#500");
162
+ assert(prRow, "promotion PR row registered by submitPr");
163
+ });
164
+
165
+ test("pollPromotion: re-running is idempotent — no duplicate promotion PR", async () => {
166
+ const { data, stores } = memData();
167
+ const { engine } = recordingEngine();
168
+ stores.plans = [
169
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null },
170
+ ];
171
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
172
+ stores.pull_requests = [{ pr_key: "o/r#299", status: "merged" }];
173
+ const state = freshRepo();
174
+
175
+ await withGithub(state, async () => {
176
+ await pollPromotion(data, engine, "tok");
177
+ await pollPromotion(data, engine, "tok");
178
+ await pollPromotion(data, engine, "tok");
179
+ });
180
+
181
+ assertEquals(state.creates.length, 1, "exactly one promotion PR across three passes");
182
+ assertEquals(stores.plans[0].promotion_pr, "o/r#500");
183
+ });
184
+
185
+ test("pollPromotion: a still-converging epic opens no promotion PR", async () => {
186
+ const { data, stores } = memData();
187
+ const { engine } = recordingEngine();
188
+ stores.plans = [
189
+ { plan_key: "o/r#296", repo: "o/r", title: "WIP", status: "done", base_branch: "epic/wip", delivery: "converging", promotion_pr: null, promotion_state: null },
190
+ ];
191
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#296", pr_key: "o/r#310" }];
192
+ stores.pull_requests = [{ pr_key: "o/r#310", status: "converging" }];
193
+ const state = freshRepo();
194
+
195
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
196
+
197
+ assertEquals(state.creates.length, 0);
198
+ assertEquals(stores.plans[0].promotion_pr, null);
199
+ assertEquals(stores.plans[0].promotion_state, null);
200
+ });
201
+
202
+ test("pollPromotion: a main-based epic has nothing to promote", async () => {
203
+ const { data, stores } = memData();
204
+ const { engine } = recordingEngine();
205
+ stores.plans = [
206
+ { plan_key: "o/r#297", repo: "o/r", title: "Direct", status: "done", base_branch: "main", delivery: "landed", promotion_pr: null, promotion_state: null },
207
+ ];
208
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#297", pr_key: "o/r#320" }];
209
+ stores.pull_requests = [{ pr_key: "o/r#320", status: "merged" }];
210
+ const state = freshRepo();
211
+
212
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
213
+
214
+ assertEquals(state.creates.length, 0);
215
+ assertEquals(stores.plans[0].promotion_pr, null);
216
+ assertEquals(stores.plans[0].promotion_state, null);
217
+ });
218
+
219
+ test("pollPromotion: reuses an existing PR from the integration branch (crash-recovery idempotency)", async () => {
220
+ const { data, stores } = memData();
221
+ const { engine } = recordingEngine();
222
+ stores.plans = [
223
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null },
224
+ ];
225
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
226
+ stores.pull_requests = [{ pr_key: "o/r#299", status: "merged" }];
227
+ const state = freshRepo();
228
+ // A prior pass created the PR on GitHub but crashed before persisting `promotion_pr`.
229
+ state.prsByHead.set("epic/test-dsl", [{ number: 777, state: "open", baseRef: "main" }]);
230
+
231
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
232
+
233
+ assertEquals(state.creates.length, 0, "existing PR reused, not duplicated");
234
+ assertEquals(stores.plans[0].promotion_pr, "o/r#777");
235
+ assertEquals(stores.plans[0].promotion_state, "open");
236
+ });
237
+
238
+ test("pollPromotion: projects `promoted` once the promotion PR merges", async () => {
239
+ const { data, stores } = memData();
240
+ const { engine } = recordingEngine();
241
+ stores.plans = [
242
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: "o/r#500", promotion_state: "open" },
243
+ ];
244
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
245
+ stores.pull_requests = [
246
+ { pr_key: "o/r#299", status: "merged" },
247
+ { pr_key: "o/r#500", status: "merged" },
248
+ ];
249
+ const state = freshRepo();
250
+
251
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
252
+
253
+ assertEquals(state.creates.length, 0);
254
+ assertEquals(stores.plans[0].promotion_state, "promoted");
255
+ });
package/app/service.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  classifyMergeability,
18
18
  coalesceTitle,
19
19
  ensureFreshHeadRun,
20
+ ensurePromotionPr,
21
+ fetchDefaultBranch,
20
22
  fetchPrHead,
21
23
  fetchPrMeta,
22
24
  fetchPrReviews,
@@ -32,6 +34,7 @@ import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
32
34
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
33
35
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
34
36
  import { planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
37
+ import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
35
38
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
36
39
  import { trialMergeAudits } from "./trialMerge.ts";
37
40
  import {
@@ -1347,6 +1350,91 @@ export async function pollDelivery(data: DataLayer) {
1347
1350
  }
1348
1351
  }
1349
1352
 
1353
+ /** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
1354
+ * promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
1355
+ * counterpart to `ensureBaseBranch`: that creates the `epic/*` branch slices merge into; this
1356
+ * delivers the fully-landed branch to the default branch. Runs AFTER `pollDelivery` so it reads the
1357
+ * freshly-projected `delivery = landed` signal.
1358
+ *
1359
+ * Per promotable plan (`isPromotable`: `delivery = landed` AND base is `epic/*`):
1360
+ * • No promotion PR yet → open ONE `epic/* → <default>` PR (idempotent against a remote head-branch
1361
+ * lookup, so a crash between GitHub-create and the `promotion_pr` write can't duplicate it),
1362
+ * record `promotion_pr`, mark `promotion_state = open`, and enroll it into the convergence + merge
1363
+ * loop via `submitPr` (a real PR that must go green + converge before it merges — never an
1364
+ * auto-merge). If the PR can't be opened this pass (no default branch resolvable, no transport),
1365
+ * leave it at `promotion_state = ready` and retry next pass.
1366
+ * • Promotion PR already recorded → project `promotion_state` from its live status
1367
+ * (`merged → promoted`, else `open`); if its `pull_requests` row is absent (a prior `submitPr`
1368
+ * failed / DB desync) re-enroll it (idempotent).
1369
+ *
1370
+ * A `main`-based epic (base is not `epic/*`) is never promotable — its slices already landed on the
1371
+ * default branch, so there is nothing to promote. Best-effort + per-plan isolated. */
1372
+ export async function pollPromotion(data: DataLayer, engine: EngineClient, token: string) {
1373
+ // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
1374
+ const statusByPrKey = new Map<string, string>();
1375
+ for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1376
+ for (const plan of await plans(data).all()) {
1377
+ if (!isPromotable(plan)) continue;
1378
+ const base = plan.base_branch;
1379
+ if (!base) continue; // narrowed by isPromotable, but keep the type-checker honest
1380
+ try {
1381
+ // Already opened → project state from the promotion PR's live status, and re-enroll it if its
1382
+ // convergence row went missing (a prior submit failed, or the app/engine store desynced).
1383
+ if (plan.promotion_pr) {
1384
+ const prStatus = statusByPrKey.get(plan.promotion_pr) ?? null;
1385
+ const nextState = derivePromotionState(true, prStatus === "merged");
1386
+ if (plan.promotion_state !== nextState) {
1387
+ await plans(data).update(plan.plan_key, { promotion_state: nextState, updated_at: now() });
1388
+ }
1389
+ if (prStatus === null) {
1390
+ const parsed = parsePr(plan.promotion_pr);
1391
+ if (parsed) await submitPr(data, engine, parsed);
1392
+ }
1393
+ continue;
1394
+ }
1395
+ // Not opened yet: this epic is ready to promote. Resolve the target (default) branch; without
1396
+ // it we can't open the PR this pass, so surface `ready` and retry.
1397
+ const target = await fetchDefaultBranch(plan.repo, token);
1398
+ if (!target || target === base) {
1399
+ // `target === base` is a defensive guard (an `epic/*` base can't be the default), but never
1400
+ // open a branch-into-itself PR. Either way, mark ready and retry.
1401
+ if (plan.promotion_state !== "ready") {
1402
+ await plans(data).update(plan.plan_key, { promotion_state: "ready", updated_at: now() });
1403
+ }
1404
+ continue;
1405
+ }
1406
+ const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
1407
+ const slicePrKeys = tasks.map((t) => t.pr_key).filter((k): k is string => !!k);
1408
+ const epicTitle = coalesceTitle(plan.title, plan.plan_key);
1409
+ const title = promotionPrTitle(base, target, epicTitle);
1410
+ const body = promotionPrBody(base, target, plan.plan_key, slicePrKeys);
1411
+ const result = await ensurePromotionPr(plan.repo, base, target, title, body, token);
1412
+ if (!result) {
1413
+ // No transport this pass — surface ready-to-promote and retry.
1414
+ if (plan.promotion_state !== "ready") {
1415
+ await plans(data).update(plan.plan_key, { promotion_state: "ready", updated_at: now() });
1416
+ }
1417
+ continue;
1418
+ }
1419
+ const promotionPrKey = `${plan.repo}#${result.number}`;
1420
+ // Persist the idempotency key + state BEFORE enrolling, so a submit failure can never lead a
1421
+ // later pass to open a second PR (it will see `promotion_pr` set and only re-enroll).
1422
+ await plans(data).update(plan.plan_key, {
1423
+ promotion_pr: promotionPrKey,
1424
+ promotion_state: "open",
1425
+ updated_at: now(),
1426
+ });
1427
+ const parsed = parsePr(promotionPrKey);
1428
+ if (parsed) await submitPr(data, engine, parsed);
1429
+ console.log(
1430
+ `[poller] promotion PR ${result.created ? "opened" : "reused"} ${promotionPrKey} (${base} -> ${target})`,
1431
+ );
1432
+ } catch (err) {
1433
+ console.error(`[poller] promotion ${plan.plan_key}: ${err}`);
1434
+ }
1435
+ }
1436
+ }
1437
+
1350
1438
  /** Reconcile each in-flight FEATURE run against its handed-off PR (fix: Feature history stuck at
1351
1439
  * `converging`). A feature run ends its own process with `status = converging` and its PR's live
1352
1440
  * outcome (merged / converged / abandoned) thereafter lives only on the `pull_requests` row keyed
@@ -1699,6 +1787,7 @@ export async function pollOnce(
1699
1787
  await pollReviews(data, engine, token);
1700
1788
  await pollMerges(data, engine, token);
1701
1789
  await pollDelivery(data);
1790
+ await pollPromotion(data, engine, token);
1702
1791
  await pollFeatureDelivery(data);
1703
1792
  await pollLineage(data);
1704
1793
  await pollFeatureEscalations(data, engine);
@@ -0,0 +1,31 @@
1
+ -- 042_plan_promotion.sql — issue #299: automate the epic integration-branch → default-branch
2
+ -- promotion PR once an epic LANDS on its custom `epic/*` integration branch.
3
+ --
4
+ -- Today an epic that targets a custom `epic/*` integration branch fans slices out that PR *into*
5
+ -- that branch; once every slice merges (`plans.delivery = landed`, projected by 029), the epic is
6
+ -- delivered on the integration branch but NOTHING opens the final `epic/* → <default>` promotion
7
+ -- PR — the operator has to notice, find the branch, and raise it by hand. This migration adds the
8
+ -- durable read/idempotency surface the poller's new `pollPromotion` pass needs to open (and then
9
+ -- track) exactly one promotion PR per landed epic, reusing the same convergence + merge protocol as
10
+ -- every other PR:
11
+ --
12
+ -- • promotion_pr — the `owner/repo#N` key of the promotion PR the poller opened for this epic,
13
+ -- or NULL until one exists. This is the PRIMARY idempotency key: a pass that
14
+ -- finds it set never opens a second PR (the poller also reconciles against a
15
+ -- remote head-branch lookup, so a crash between GitHub-create and this write
16
+ -- can never duplicate the PR either).
17
+ -- • promotion_state — the epic-card progression for the landed→delivered arc (issue #298's
18
+ -- "keep landed epics visible until acknowledged"): one of
19
+ -- 'ready' — landed on an `epic/*` base, promotion PR not yet opened.
20
+ -- 'open' — the promotion PR is open and converging toward merge.
21
+ -- 'promoted' — the promotion PR merged; the epic is delivered on the
22
+ -- default branch.
23
+ -- NULL until the epic first becomes promotable (grandfathers pre-#299 rows
24
+ -- and every `main`-based epic, which has nothing to promote).
25
+ --
26
+ -- Additive/derived only: no change to the plan lifecycle (`status`) — both columns are projected
27
+ -- idempotently by the poller, mirroring the `delivery` / `delivery_label` read-model columns (029).
28
+ -- Numbered after the current highest prefix on origin/main (041); the runner wraps each file in its
29
+ -- own transaction, so this file must NOT contain BEGIN/COMMIT.
30
+ ALTER TABLE plans ADD COLUMN promotion_pr TEXT;
31
+ ALTER TABLE plans ADD COLUMN promotion_state TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.88.1",
3
+ "version": "0.89.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",
@@ -61,6 +61,7 @@
61
61
  { "field": "epic_phase", "header": "Phase" },
62
62
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
63
63
  { "field": "delivery", "header": "Delivery" },
64
+ { "field": "promotion_state", "header": "Promotion" },
64
65
  { "field": "wave_label", "header": "Wave" },
65
66
  { "field": "task_count", "header": "Tasks" },
66
67
  { "field": "updated_at", "header": "Updated", "width": "9rem" }
@@ -72,6 +73,7 @@
72
73
  { "field": "issue_number", "label": "Issue number" },
73
74
  { "field": "base_branch", "label": "Base branch (blank = repo default)" },
74
75
  { "field": "delivery_label", "label": "Delivery rollup (slices merged / converging)" },
76
+ { "field": "promotion_pr", "label": "Promotion PR (epic/* → default branch)" },
75
77
  { "field": "outcome", "label": "Outcome" }
76
78
  ]
77
79
  }
@@ -79,6 +79,7 @@
79
79
  { "field": "epic_phase", "header": "Phase" },
80
80
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
81
81
  { "field": "delivery", "header": "Delivery" },
82
+ { "field": "promotion_state", "header": "Promotion" },
82
83
  { "field": "base_branch", "header": "Base branch" },
83
84
  { "field": "wave_label", "header": "Wave" },
84
85
  { "field": "task_count", "header": "Tasks" },