@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
|
@@ -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;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- 043_pr_epic_phase.sql — issue #304: surface epic / cross-slice lineage on the Convergence
|
|
2
|
+
-- PR-row detail. An operator triaging a PR (especially an escalation) needs to see, at the point of
|
|
3
|
+
-- decision, that the PR is a slice of an epic and which phase that epic is in — without navigating to
|
|
4
|
+
-- the Lineage page. The sibling-slice roster is already expressible in page JSON today (a `plan_tasks`
|
|
5
|
+
-- child grid joined `pull_requests.root_request_key → plan_tasks.plan_key`), but the epic's PHASE
|
|
6
|
+
-- lives on `plans.epic_phase` (038) / `lineage_threads.stage_label` (037), NOT on `pull_requests`, so
|
|
7
|
+
-- a `detail.field` cannot read it directly.
|
|
8
|
+
--
|
|
9
|
+
-- This migration adds the one missing projection column the read model needs:
|
|
10
|
+
--
|
|
11
|
+
-- • `pull_requests.epic_phase_label` — the parent epic's phase label for an epic slice PR (e.g.
|
|
12
|
+
-- "Implementing (wave 3/5)"), NULL for a feature/self-rooted PR that is not an epic slice.
|
|
13
|
+
-- Written idempotently on the SAME lineage poll path that maintains the lineage projection
|
|
14
|
+
-- (`app/lineage.ts` `pollLineage` → `projectEpicPhaseLabels`), mirroring the existing write-time
|
|
15
|
+
-- projection convention (`plans.epic_phase`, `plans.delivery_label`, `feature_runs.delivery_label`).
|
|
16
|
+
-- The poller prefers the epic's stamped `epic_phase`, falling back to the thread's delivery-rollup
|
|
17
|
+
-- stage label for a grandfathered epic that never stamped one, and clears the column to NULL if a
|
|
18
|
+
-- PR is ever re-rooted off an epic — so no stale epic label can survive.
|
|
19
|
+
--
|
|
20
|
+
-- Forward-only, additive (expand): a nullable TEXT column with no default, display-only, that never
|
|
21
|
+
-- gates control flow. Numbered after the current highest prefix (042). The runner wraps each file in
|
|
22
|
+
-- its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
23
|
+
ALTER TABLE pull_requests ADD COLUMN epic_phase_label TEXT;
|
|
24
|
+
|
|
25
|
+
-- Backfill pre-existing rows so the epic panel is populated at deploy time, not only after the first
|
|
26
|
+
-- poll pass writes it. An epic slice PR is one whose `root_request_key` is an epic `plans.plan_key`
|
|
27
|
+
-- (submitPr threads the epic origin key onto every slice PR, and migration 037 backfilled legacy
|
|
28
|
+
-- rows the same way); stamp it with that epic's `plans.epic_phase`. A feature/self-rooted PR has no
|
|
29
|
+
-- matching plan, so it stays NULL — no empty epic panel for a non-epic PR. Idempotent, and the poller
|
|
30
|
+
-- reconciles the delivery-rollup fallback (for a plan whose `epic_phase` is NULL) on its next pass.
|
|
31
|
+
UPDATE pull_requests SET epic_phase_label = (
|
|
32
|
+
SELECT p.epic_phase FROM plans p WHERE p.plan_key = pull_requests.root_request_key
|
|
33
|
+
)
|
|
34
|
+
WHERE EXISTS (
|
|
35
|
+
SELECT 1 FROM plans p WHERE p.plan_key = pull_requests.root_request_key
|
|
36
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.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" },
|