@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 +7 -0
- package/app/github.test.ts +94 -1
- package/app/github.ts +150 -0
- package/app/migration042.test.ts +51 -0
- package/app/plan.ts +11 -0
- package/app/promotion.test.ts +56 -0
- package/app/promotion.ts +84 -0
- package/app/promotionPoll.test.ts +255 -0
- package/app/service.ts +89 -0
- package/db/migrations/042_plan_promotion.sql +31 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +2 -0
- package/pages/epic.page.json +1 -0
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
|
|
package/app/github.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/app/promotion.ts
ADDED
|
@@ -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.
|
|
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
|
}
|
package/pages/epic.page.json
CHANGED
|
@@ -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" },
|