@nanobpm/nano-workforce 0.88.0 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/contracts.ts +8 -0
- package/app/feature.ts +8 -4
- package/app/featureBlocked.test.ts +52 -5
- package/app/featureEscalation.test.ts +60 -5
- package/app/github.test.ts +94 -1
- package/app/github.ts +150 -0
- package/app/migration041.test.ts +79 -0
- package/app/migration042.test.ts +51 -0
- package/app/plan.ts +92 -0
- package/app/planDeps.test.ts +192 -0
- package/app/pollUserTasks.test.ts +62 -5
- package/app/promotion.test.ts +56 -0
- package/app/promotion.ts +84 -0
- package/app/promotionPoll.test.ts +255 -0
- package/app/service.ts +93 -4
- package/db/migrations/041_inter_epic_plan_deps.sql +38 -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
|
@@ -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
|
|
@@ -1410,7 +1498,7 @@ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClie
|
|
|
1410
1498
|
for (const run of candidates) {
|
|
1411
1499
|
if (!run.process_key) continue;
|
|
1412
1500
|
try {
|
|
1413
|
-
const tasks = await engine.
|
|
1501
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
1414
1502
|
const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
|
|
1415
1503
|
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1416
1504
|
const patch = deriveFeatureEscalationPatch(run, parked);
|
|
@@ -1444,7 +1532,7 @@ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient)
|
|
|
1444
1532
|
for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
|
|
1445
1533
|
if (!run.process_key) continue;
|
|
1446
1534
|
try {
|
|
1447
|
-
const tasks = await engine.
|
|
1535
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
1448
1536
|
const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
|
|
1449
1537
|
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1450
1538
|
const patch = deriveFeatureBlockedPatch(run, parked);
|
|
@@ -1577,7 +1665,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1577
1665
|
planSeen.add(plan.plan_key);
|
|
1578
1666
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1579
1667
|
try {
|
|
1580
|
-
tasks = await engine.
|
|
1668
|
+
tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
|
|
1581
1669
|
} catch (err) {
|
|
1582
1670
|
console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
|
|
1583
1671
|
continue;
|
|
@@ -1632,7 +1720,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1632
1720
|
prSeen.add(pr.pr_key);
|
|
1633
1721
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1634
1722
|
try {
|
|
1635
|
-
tasks = await engine.
|
|
1723
|
+
tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
|
|
1636
1724
|
} catch (err) {
|
|
1637
1725
|
console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
|
|
1638
1726
|
continue;
|
|
@@ -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,38 @@
|
|
|
1
|
+
-- 041_inter_epic_plan_deps.sql — issue #292 slice S1: first-class INTER-epic dependency edge.
|
|
2
|
+
--
|
|
3
|
+
-- `plan_task_deps` (005_plan_deps.sql) records the INTRA-epic task DAG — edges that order tasks
|
|
4
|
+
-- *within a single plan* into waves. There is no way today to say "epic B depends-on epic A": that
|
|
5
|
+
-- the whole of epic B must wait until epic A has published a capability before B may fan out.
|
|
6
|
+
--
|
|
7
|
+
-- This table adds that second, coarser grain: one row per INTER-epic edge. `plan_key` is the
|
|
8
|
+
-- dependent/consumer epic that waits; `depends_on_plan_key` is the producer epic it waits for. The
|
|
9
|
+
-- edge also carries the GATING CONTRACT DESCRIPTOR the later capability probe (slice S3) needs to
|
|
10
|
+
-- resolve which published `pkg@version` first carries the awaited capability: `package` is the
|
|
11
|
+
-- producer's published package name, and `capability_ref` is the producer epic's issue handle used
|
|
12
|
+
-- to resolve that version. This slice (S1) only lands the durable schema + typed read/write surface;
|
|
13
|
+
-- admission (S2), lowering into a readiness gate (S3), and visibility (S4) build on it later.
|
|
14
|
+
--
|
|
15
|
+
-- Constraints mirror how `plan_task_deps` is constrained:
|
|
16
|
+
-- • PRIMARY KEY (plan_key, depends_on_plan_key) — one edge per consumer→producer pair, so a
|
|
17
|
+
-- re-submitted set cannot duplicate an edge.
|
|
18
|
+
-- • CHECK (plan_key <> depends_on_plan_key) — an epic cannot depend on itself.
|
|
19
|
+
-- • plan_key REFERENCES plans(plan_key) — the consumer is an admitted plan (as plan_task_deps FKs
|
|
20
|
+
-- its plan_key). `depends_on_plan_key` is intentionally NOT FK-constrained: a batch admission
|
|
21
|
+
-- (S2) may insert edges before every producer row exists, and the set validator enforces that
|
|
22
|
+
-- every edge names a submitted epic. An index on plan_key backs the inbound read.
|
|
23
|
+
--
|
|
24
|
+
-- Numbered after the current highest prefix on origin/main (040); the runner wraps each file in its
|
|
25
|
+
-- own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
26
|
+
|
|
27
|
+
CREATE TABLE plan_deps (
|
|
28
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key), -- dependent/consumer epic that waits
|
|
29
|
+
depends_on_plan_key TEXT NOT NULL, -- producer epic it waits for
|
|
30
|
+
package TEXT NOT NULL, -- producer's published package name
|
|
31
|
+
capability_ref TEXT NOT NULL, -- producer epic issue handle → pkg@version
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (plan_key, depends_on_plan_key),
|
|
34
|
+
CHECK (plan_key <> depends_on_plan_key)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX idx_plan_deps_plan ON plan_deps(plan_key);
|
|
38
|
+
CREATE INDEX idx_plan_deps_producer ON plan_deps(depends_on_plan_key);
|
|
@@ -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" },
|