@nanobpm/nano-workforce 0.88.1 → 0.90.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 +14 -0
- package/app/github.test.ts +94 -1
- package/app/github.ts +150 -0
- package/app/lineage.test.ts +25 -5
- package/app/lineage.ts +60 -5
- package/app/migration042.test.ts +51 -0
- package/app/migration043.test.ts +62 -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/db/migrations/043_pr_epic_phase.sql +36 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +2 -0
- package/pages/epic.page.json +1 -0
- package/pages/home.page.json +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.90.0](https://github.com/nanobpm/nano-workforce/compare/v0.89.0...v0.90.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **convergence:** surface epic / cross-slice lineage on the PR-row detail ([#307](https://github.com/nanobpm/nano-workforce/issues/307)) ([7010b88](https://github.com/nanobpm/nano-workforce/commit/7010b88889529aec173c552bab2a1e7b881ba9da)), closes [nanobpm/nano-workforce#304](https://github.com/nanobpm/nano-workforce/issues/304)
|
|
7
|
+
|
|
8
|
+
# [0.89.0](https://github.com/nanobpm/nano-workforce/compare/v0.88.1...v0.89.0) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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))
|
|
14
|
+
|
|
1
15
|
## [0.88.1](https://github.com/nanobpm/nano-workforce/compare/v0.88.0...v0.88.1) (2026-08-18)
|
|
2
16
|
|
|
3
17
|
|
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
|
+
}
|
package/app/lineage.test.ts
CHANGED
|
@@ -75,7 +75,7 @@ test("feature: an escalated run surfaces the escalation stage", () => {
|
|
|
75
75
|
|
|
76
76
|
test("epic: rolls up N slice PRs and stays active while any is in flight", () => {
|
|
77
77
|
const t = deriveLineage(
|
|
78
|
-
{ kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
|
|
78
|
+
{ kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1", epicPhase: "Implementing (wave 2/3)" },
|
|
79
79
|
[
|
|
80
80
|
pr({ prKey: "o/r#10", status: "merged" }),
|
|
81
81
|
pr({ prKey: "o/r#11", status: "converging", processKey: "c11" }),
|
|
@@ -85,6 +85,7 @@ test("epic: rolls up N slice PRs and stays active while any is in flight", () =>
|
|
|
85
85
|
assertEquals(t.kind, "epic");
|
|
86
86
|
assertEquals(t.stage, "converging");
|
|
87
87
|
assertEquals(t.stageLabel, "1/3 slices merged, 1 converging");
|
|
88
|
+
assertEquals(t.epicPhaseLabel, "Implementing (wave 2/3)", "an epic thread carries its stamped epic_phase down to member PRs");
|
|
88
89
|
assertEquals(t.processKey, "c11");
|
|
89
90
|
assertEquals(t.prCount, 3);
|
|
90
91
|
assert(t.active);
|
|
@@ -92,17 +93,18 @@ test("epic: rolls up N slice PRs and stays active while any is in flight", () =>
|
|
|
92
93
|
|
|
93
94
|
test("epic: all slices merged settles as merged", () => {
|
|
94
95
|
const t = deriveLineage(
|
|
95
|
-
{ kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
|
|
96
|
+
{ kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1", epicPhase: null },
|
|
96
97
|
[pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "merged" })],
|
|
97
98
|
);
|
|
98
99
|
assertEquals(t.stage, "merged");
|
|
99
100
|
assertEquals(t.stageLabel, "2/2 slices merged");
|
|
101
|
+
assertEquals(t.epicPhaseLabel, "2/2 slices merged", "a grandfathered epic (no epic_phase) falls back to its delivery-rollup stage label");
|
|
100
102
|
assert(!t.active);
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
test("epic: mixed terminal (none in flight, not all merged) is resolved, not landed", () => {
|
|
104
106
|
const t = deriveLineage(
|
|
105
|
-
{ kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "done", processKey: "e1" },
|
|
107
|
+
{ kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "done", processKey: "e1", epicPhase: "Finalizing" },
|
|
106
108
|
[pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "abandoned" })],
|
|
107
109
|
);
|
|
108
110
|
assertEquals(t.stage, "resolved");
|
|
@@ -111,13 +113,23 @@ test("epic: mixed terminal (none in flight, not all merged) is resolved, not lan
|
|
|
111
113
|
|
|
112
114
|
test("epic: planning with no PRs yet", () => {
|
|
113
115
|
const t = deriveLineage(
|
|
114
|
-
{ kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "planning", processKey: "e1" },
|
|
116
|
+
{ kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "planning", processKey: "e1", epicPhase: "Planning" },
|
|
115
117
|
[],
|
|
116
118
|
);
|
|
117
119
|
assertEquals(t.stage, "planning");
|
|
118
120
|
assert(t.active);
|
|
119
121
|
});
|
|
120
122
|
|
|
123
|
+
test("feature/self-rooted threads carry no epic phase label", () => {
|
|
124
|
+
const feat = deriveLineage(
|
|
125
|
+
{ kind: "feature", key: "o/r#1", title: "X", issueUrl: "u", status: "converging", processKey: "f1" },
|
|
126
|
+
[pr({ prKey: "o/r#2", status: "converging" })],
|
|
127
|
+
);
|
|
128
|
+
assertEquals(feat.epicPhaseLabel, null, "a feature PR is not an epic slice");
|
|
129
|
+
const self = deriveLineage({ kind: "pr", key: "o/r#5" }, [pr({ prKey: "o/r#5", status: "converging" })]);
|
|
130
|
+
assertEquals(self.epicPhaseLabel, null, "a self-rooted PR is not an epic slice");
|
|
131
|
+
});
|
|
132
|
+
|
|
121
133
|
// ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
|
|
122
134
|
|
|
123
135
|
test("pr: a human/webhook PR with no origin is its own root", () => {
|
|
@@ -174,7 +186,7 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
|
|
|
174
186
|
{ feature_key: "o/r#1", title: "Feature", issue_url: "u1", status: "converging", process_key: "f1", pr_key: "o/r#100" },
|
|
175
187
|
];
|
|
176
188
|
stores.plans = [
|
|
177
|
-
{ plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1" },
|
|
189
|
+
{ plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1", epic_phase: "Implementing (wave 1/2)" },
|
|
178
190
|
];
|
|
179
191
|
stores.plan_tasks = [
|
|
180
192
|
{ id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
|
|
@@ -212,6 +224,14 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
|
|
|
212
224
|
assertEquals(human?.stage, "merged");
|
|
213
225
|
assertEquals(human?.active, 0);
|
|
214
226
|
|
|
227
|
+
// Epic-phase projection (#304): each epic slice PR gets its parent epic's phase label; the feature
|
|
228
|
+
// and self-rooted PRs (not epic slices) are left NULL, so their PR-row detail shows no epic panel.
|
|
229
|
+
const prById = (k: string) => stores.pull_requests.find((r: any) => r.pr_key === k);
|
|
230
|
+
assertEquals(prById("o/r#200").epic_phase_label, "Implementing (wave 1/2)", "epic slice S1 carries the epic phase");
|
|
231
|
+
assertEquals(prById("o/r#201").epic_phase_label, "Implementing (wave 1/2)", "epic slice S2 carries the epic phase");
|
|
232
|
+
assertEquals(prById("o/r#100").epic_phase_label ?? null, null, "a feature PR is not an epic slice");
|
|
233
|
+
assertEquals(prById("o/r#300").epic_phase_label ?? null, null, "a self-rooted PR is not an epic slice");
|
|
234
|
+
|
|
215
235
|
// Idempotent: a second pass with no state change writes nothing new (same row count, same ts).
|
|
216
236
|
const before = stores.lineage_threads.map((r: LineageThreadRow) => r.updated_at);
|
|
217
237
|
await pollLineage(data);
|
package/app/lineage.ts
CHANGED
|
@@ -56,6 +56,10 @@ export type LineageOrigin =
|
|
|
56
56
|
issueUrl: string | null;
|
|
57
57
|
status: string;
|
|
58
58
|
processKey: string | null;
|
|
59
|
+
// The epic's derived domain phase (`plans.epic_phase`, 038_plan_epic_phase.sql) — e.g.
|
|
60
|
+
// "Implementing (wave 3/5)". NULL for pre-#261 epics that never stamped a phase; the thread
|
|
61
|
+
// then falls back to its delivery-rollup `stageLabel` for the projected `epicPhaseLabel`.
|
|
62
|
+
epicPhase: string | null;
|
|
59
63
|
}
|
|
60
64
|
| {
|
|
61
65
|
// A human/webhook PR with no originating request: its own root.
|
|
@@ -73,6 +77,12 @@ export interface LineageThread {
|
|
|
73
77
|
stage: LineageStage;
|
|
74
78
|
/** Human narrative rollup for the timeline (e.g. "Converging (round 2)", "3/5 slices merged, …"). */
|
|
75
79
|
stageLabel: string;
|
|
80
|
+
/** The epic-phase/stage label projected onto each member PR's `pull_requests.epic_phase_label`, so
|
|
81
|
+
* the Convergence PR-row detail can show an epic slice its parent epic's phase (issue #304). For an
|
|
82
|
+
* epic thread it is the epic's `epic_phase` (e.g. "Implementing (wave 3/5)"), falling back to the
|
|
83
|
+
* delivery-rollup `stageLabel` when the epic never stamped a phase. NULL for feature/self-rooted
|
|
84
|
+
* threads — their member PRs are not epic slices, so the epic panel stays empty for them. */
|
|
85
|
+
epicPhaseLabel: string | null;
|
|
76
86
|
/** The active-frontier process instance (for the processExplorer link), best-effort. */
|
|
77
87
|
processKey: string | null;
|
|
78
88
|
prKeys: string[];
|
|
@@ -238,6 +248,12 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
|
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
const active = !TERMINAL_STAGES.includes(stage);
|
|
251
|
+
// Epic slices carry their parent epic's phase down to the PR-row detail (issue #304): prefer the
|
|
252
|
+
// epic's own stamped `epic_phase`, falling back to the delivery-rollup `stageLabel` for a
|
|
253
|
+
// grandfathered epic that never stamped one. Feature/self-rooted threads are not epics, so their
|
|
254
|
+
// member PRs get no epic label.
|
|
255
|
+
const epicPhaseLabel =
|
|
256
|
+
origin.kind === "epic" ? (origin.epicPhase ?? stageLabel) : null;
|
|
241
257
|
return {
|
|
242
258
|
rootRequestKey: origin.key,
|
|
243
259
|
kind: origin.kind,
|
|
@@ -245,6 +261,7 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
|
|
|
245
261
|
issueUrl: origin.kind === "pr" ? null : origin.issueUrl,
|
|
246
262
|
stage,
|
|
247
263
|
stageLabel,
|
|
264
|
+
epicPhaseLabel,
|
|
248
265
|
processKey,
|
|
249
266
|
prKeys,
|
|
250
267
|
prCount: prKeys.length,
|
|
@@ -287,6 +304,9 @@ interface PrRow {
|
|
|
287
304
|
process_key: string | null;
|
|
288
305
|
outcome: string | null;
|
|
289
306
|
root_request_key: string | null;
|
|
307
|
+
// Epic-phase projection this module maintains (issue #304, migration 043): the parent epic's phase
|
|
308
|
+
// label for an epic slice PR, NULL otherwise. Read here only to keep the write idempotent.
|
|
309
|
+
epic_phase_label: string | null;
|
|
290
310
|
}
|
|
291
311
|
|
|
292
312
|
const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
|
|
@@ -324,7 +344,9 @@ function toLineagePr(row: PrRow): LineagePr {
|
|
|
324
344
|
|
|
325
345
|
/** Assemble the origin + PR set for every root from the live gateway rows, then derive each thread.
|
|
326
346
|
* Reused by both `getLineage` (single root, on demand) and `pollLineage` (all roots, projected). */
|
|
327
|
-
async function collectThreads(
|
|
347
|
+
async function collectThreads(
|
|
348
|
+
data: DataLayer,
|
|
349
|
+
): Promise<{ threads: Map<string, LineageThread>; allPrs: PrRow[] }> {
|
|
328
350
|
const allPrs = await prRows(data).all();
|
|
329
351
|
const prByKey = new Map<string, PrRow>();
|
|
330
352
|
for (const pr of allPrs) prByKey.set(pr.pr_key, pr);
|
|
@@ -388,7 +410,7 @@ async function collectThreads(data: DataLayer): Promise<Map<string, LineageThrea
|
|
|
388
410
|
threads.set(rootKey, deriveLineage({ kind: "pr", key: rootKey }, prs.map(toLineagePr)));
|
|
389
411
|
}
|
|
390
412
|
|
|
391
|
-
return threads;
|
|
413
|
+
return { threads, allPrs };
|
|
392
414
|
}
|
|
393
415
|
|
|
394
416
|
/** Union the PRs a feature root owns: those threaded to it + its own denormalised `pr_key`. */
|
|
@@ -446,6 +468,7 @@ function epicOrigin(plan: Plan): LineageOrigin {
|
|
|
446
468
|
issueUrl: plan.issue_url,
|
|
447
469
|
status: plan.status,
|
|
448
470
|
processKey: plan.process_key,
|
|
471
|
+
epicPhase: plan.epic_phase,
|
|
449
472
|
};
|
|
450
473
|
}
|
|
451
474
|
|
|
@@ -455,7 +478,7 @@ export async function getLineage(
|
|
|
455
478
|
data: DataLayer,
|
|
456
479
|
rootRequestKey: string,
|
|
457
480
|
): Promise<LineageThread | null> {
|
|
458
|
-
const threads = await collectThreads(data);
|
|
481
|
+
const { threads } = await collectThreads(data);
|
|
459
482
|
return threads.get(rootRequestKey) ?? null;
|
|
460
483
|
}
|
|
461
484
|
|
|
@@ -463,7 +486,7 @@ export async function getLineage(
|
|
|
463
486
|
* deterministic order (the projection has no per-thread timestamp to sort on, and equal-`active`
|
|
464
487
|
* ties would otherwise be nondeterministic across passes). */
|
|
465
488
|
export async function listLineage(data: DataLayer): Promise<LineageThread[]> {
|
|
466
|
-
const threads = await collectThreads(data);
|
|
489
|
+
const { threads } = await collectThreads(data);
|
|
467
490
|
return [...threads.values()].sort(
|
|
468
491
|
(a, b) => Number(b.active) - Number(a.active) || a.rootRequestKey.localeCompare(b.rootRequestKey),
|
|
469
492
|
);
|
|
@@ -474,8 +497,9 @@ export async function listLineage(data: DataLayer): Promise<LineageThread[]> {
|
|
|
474
497
|
* when the projection actually changes. Best-effort; per-root failures are isolated. */
|
|
475
498
|
export async function pollLineage(data: DataLayer): Promise<void> {
|
|
476
499
|
let threads: Map<string, LineageThread>;
|
|
500
|
+
let allPrs: PrRow[];
|
|
477
501
|
try {
|
|
478
|
-
threads = await collectThreads(data);
|
|
502
|
+
({ threads, allPrs } = await collectThreads(data));
|
|
479
503
|
} catch (err) {
|
|
480
504
|
console.error(`[poller] lineage collect: ${err}`);
|
|
481
505
|
return;
|
|
@@ -534,4 +558,35 @@ export async function pollLineage(data: DataLayer): Promise<void> {
|
|
|
534
558
|
console.error(`[poller] lineage ${thread.rootRequestKey}: ${err}`);
|
|
535
559
|
}
|
|
536
560
|
}
|
|
561
|
+
await projectEpicPhaseLabels(data, threads, allPrs);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Denormalise each epic thread's phase label down onto its member PRs' `pull_requests.epic_phase_label`
|
|
565
|
+
* (issue #304), so the Convergence PR-row detail can show an escalated slice its parent epic's phase
|
|
566
|
+
* without a cross-join to `plans` / `lineage_threads`. Every PR belongs to exactly one thread, so a
|
|
567
|
+
* PR whose thread is a feature/self-root is cleared to NULL — no stale epic label survives if a PR is
|
|
568
|
+
* re-rooted. Idempotent: writes only the rows whose label actually changed. Mirrors the write-time
|
|
569
|
+
* projection convention (`delivery_label`, `epic_phase`). Best-effort; failures are isolated. */
|
|
570
|
+
async function projectEpicPhaseLabels(
|
|
571
|
+
data: DataLayer,
|
|
572
|
+
threads: Map<string, LineageThread>,
|
|
573
|
+
allPrs: PrRow[],
|
|
574
|
+
): Promise<void> {
|
|
575
|
+
// Desired label per member PR: an epic thread stamps its `epicPhaseLabel`, every other thread NULL.
|
|
576
|
+
const desired = new Map<string, string | null>();
|
|
577
|
+
for (const thread of threads.values()) {
|
|
578
|
+
for (const key of thread.prKeys) desired.set(key, thread.epicPhaseLabel);
|
|
579
|
+
}
|
|
580
|
+
// Reuse the PR rows `collectThreads` already read this pass rather than re-scanning the whole
|
|
581
|
+
// `pull_requests` table — the projection only writes the rows whose label actually changed.
|
|
582
|
+
const table = prRows(data);
|
|
583
|
+
for (const pr of allPrs) {
|
|
584
|
+
const want = desired.get(pr.pr_key) ?? null;
|
|
585
|
+
if ((pr.epic_phase_label ?? null) === want) continue; // steady state — no write
|
|
586
|
+
try {
|
|
587
|
+
await table.update(pr.pr_key, { epic_phase_label: want });
|
|
588
|
+
} catch (err) {
|
|
589
|
+
console.error(`[poller] lineage epic-phase ${pr.pr_key}: ${err}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
537
592
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Regression guard for migration 043's epic_phase_label backfill (issue #304). The Convergence
|
|
2
|
+
// PR-row detail surfaces an epic slice's parent-epic phase via `pull_requests.epic_phase_label`. The
|
|
3
|
+
// column is maintained going forward by `pollLineage` (`projectEpicPhaseLabels`), but pre-existing
|
|
4
|
+
// rows must be backfilled at deploy time so the epic panel is populated before the first poll pass:
|
|
5
|
+
// an epic slice PR (its `root_request_key` is an epic `plans.plan_key`) takes that epic's
|
|
6
|
+
// `plans.epic_phase`; a feature/self-rooted PR (no matching plan) stays NULL — no empty epic panel.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { assertEquals } from "#test-assert";
|
|
12
|
+
|
|
13
|
+
function migratedDb(): DatabaseSync {
|
|
14
|
+
const db = new DatabaseSync(":memory:");
|
|
15
|
+
// Minimal pre-043 shape: pull_requests WITH root_request_key (037) but WITHOUT epic_phase_label
|
|
16
|
+
// (043 ADD COLUMNs it), plus the plans table the backfill joins on for the epic phase.
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, repo TEXT, number INTEGER, url TEXT,
|
|
19
|
+
status TEXT, root_request_key TEXT, created_at TEXT, updated_at TEXT);
|
|
20
|
+
CREATE TABLE plans (plan_key TEXT PRIMARY KEY, epic_phase TEXT);
|
|
21
|
+
`);
|
|
22
|
+
const ins = (k: string, root: string) =>
|
|
23
|
+
db
|
|
24
|
+
.prepare(
|
|
25
|
+
`INSERT INTO pull_requests (pr_key, repo, number, url, status, root_request_key, created_at, updated_at)
|
|
26
|
+
VALUES (?, 'o/r', 1, 'u', 'converging', ?, 't', 't')`,
|
|
27
|
+
)
|
|
28
|
+
.run(k, root);
|
|
29
|
+
ins("o/r#20", "o/r#2"); // epic slice of a phased epic
|
|
30
|
+
ins("o/r#21", "o/r#3"); // epic slice of a grandfathered epic (epic_phase NULL)
|
|
31
|
+
ins("o/r#30", "o/r#30"); // self-rooted (human/webhook) PR — root is its own key, no plan
|
|
32
|
+
ins("o/r#40", "o/r#1"); // feature-rooted PR — root is a feature key, no matching plan
|
|
33
|
+
db.prepare("INSERT INTO plans (plan_key, epic_phase) VALUES ('o/r#2', 'Implementing (wave 3/5)')").run();
|
|
34
|
+
db.prepare("INSERT INTO plans (plan_key, epic_phase) VALUES ('o/r#3', NULL)").run();
|
|
35
|
+
|
|
36
|
+
const sql = readFileSync(
|
|
37
|
+
fileURLToPath(new URL("../db/migrations/043_pr_epic_phase.sql", import.meta.url)),
|
|
38
|
+
"utf8",
|
|
39
|
+
);
|
|
40
|
+
db.exec(sql);
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("migration 043 backfills epic slice PRs with their epic's phase and leaves non-epic PRs NULL", () => {
|
|
45
|
+
const db = migratedDb();
|
|
46
|
+
const labelOf = (k: string) =>
|
|
47
|
+
(
|
|
48
|
+
db.prepare("SELECT epic_phase_label AS l FROM pull_requests WHERE pr_key = ?").get(k) as {
|
|
49
|
+
l: string | null;
|
|
50
|
+
}
|
|
51
|
+
).l;
|
|
52
|
+
|
|
53
|
+
// An epic slice roots on its epic's plan_key, so it inherits that epic's stamped phase.
|
|
54
|
+
assertEquals(labelOf("o/r#20"), "Implementing (wave 3/5)");
|
|
55
|
+
// A slice of a grandfathered epic (epic_phase NULL) stays NULL — the poller reconciles the
|
|
56
|
+
// delivery-rollup fallback on its next pass; the migration only seeds the stamped phase.
|
|
57
|
+
assertEquals(labelOf("o/r#21"), null);
|
|
58
|
+
// A self-rooted PR has no matching plan → no epic panel.
|
|
59
|
+
assertEquals(labelOf("o/r#30"), null);
|
|
60
|
+
// A feature-rooted PR has no matching plan → no epic panel.
|
|
61
|
+
assertEquals(labelOf("o/r#40"), null);
|
|
62
|
+
});
|
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
|
}
|