@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
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
|
}
|
|
@@ -138,6 +149,87 @@ export interface PlanTaskDep {
|
|
|
138
149
|
export const planTaskDeps = (data: DataLayer) =>
|
|
139
150
|
data.table<PlanTaskDep>("plan_task_deps", "plan_key");
|
|
140
151
|
|
|
152
|
+
/** One INTER-epic dependency edge in the plan-set DAG (issue #292, slice S1): the epic `plan_key`
|
|
153
|
+
* waits for the producer epic `depends_on_plan_key` to publish a capability before it may fan out.
|
|
154
|
+
*
|
|
155
|
+
* This is the coarser sibling of {@link PlanTaskDep} (which orders TASKS *within* one epic into
|
|
156
|
+
* waves). A `PlanDep` orders whole EPICS relative to each other. The edge additionally carries the
|
|
157
|
+
* gating contract descriptor the capability probe (slice S3) resolves against:
|
|
158
|
+
* • `package` — the producer epic's published package name, and
|
|
159
|
+
* • `capability_ref` — the producer epic's issue handle, used to resolve which published
|
|
160
|
+
* `pkg@version` FIRST carries the awaited capability (late-bound into the dependent's build).
|
|
161
|
+
* Keyed on `plan_key` (the dependent) so a single delete clears a dependent's whole inbound edge set
|
|
162
|
+
* — mirroring how `plan_task_deps` is keyed on `plan_key`. See db/migrations/041_inter_epic_plan_deps.sql
|
|
163
|
+
* for the durable constraints (one edge per consumer→producer pair; no self-edge). */
|
|
164
|
+
export interface PlanDep {
|
|
165
|
+
plan_key: string;
|
|
166
|
+
depends_on_plan_key: string;
|
|
167
|
+
package: string;
|
|
168
|
+
capability_ref: string;
|
|
169
|
+
created_at: string;
|
|
170
|
+
}
|
|
171
|
+
export const planDeps = (data: DataLayer) => data.table<PlanDep>("plan_deps", "plan_key");
|
|
172
|
+
|
|
173
|
+
/** The fields an admission caller supplies for one inter-epic edge; `created_at` is stamped here. */
|
|
174
|
+
export type PlanDepInput = Omit<PlanDep, "created_at">;
|
|
175
|
+
|
|
176
|
+
/** Record one inter-epic dependency edge, enforcing the schema's two invariants at the app layer too
|
|
177
|
+
* (the durable table backstops both, but the in-memory test data layer does not): an epic may not
|
|
178
|
+
* depend on itself, and a consumer→producer edge is recorded at most once. A duplicate re-submission
|
|
179
|
+
* is a no-op that returns the existing row rather than throwing, so batch admission (S2) stays
|
|
180
|
+
* idempotent; a self-edge is a programming/validation error and throws. */
|
|
181
|
+
export async function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promise<PlanDep> {
|
|
182
|
+
if (edge.plan_key === edge.depends_on_plan_key) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`plan_deps: self-edge rejected — epic ${edge.plan_key} cannot depend on itself`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const table = planDeps(data);
|
|
188
|
+
const match = { plan_key: edge.plan_key, depends_on_plan_key: edge.depends_on_plan_key };
|
|
189
|
+
const existing = (await table.find(match))[0];
|
|
190
|
+
if (existing) return existing;
|
|
191
|
+
const row: PlanDep = { ...edge, created_at: now() };
|
|
192
|
+
try {
|
|
193
|
+
await table.insert(row);
|
|
194
|
+
return row;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// A concurrent caller may have inserted the same consumer→producer pair between our find and
|
|
197
|
+
// our insert (classic check-then-insert race); the composite PRIMARY KEY is the durable
|
|
198
|
+
// backstop that rejects the loser. Honour the "duplicate re-submission is a no-op" contract by
|
|
199
|
+
// re-reading and returning the winning row rather than surfacing the constraint error. Only a
|
|
200
|
+
// genuine non-collision failure (the pair still absent after the re-read) is re-raised.
|
|
201
|
+
const raced = (await table.find(match))[0];
|
|
202
|
+
if (raced) return raced;
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** All INBOUND edges for `planKey` — i.e. every producer epic this dependent waits on. Empty for a
|
|
208
|
+
* root epic (no inter-epic dependencies). */
|
|
209
|
+
export function inboundPlanDeps(data: DataLayer, planKey: string): Promise<PlanDep[]> {
|
|
210
|
+
return planDeps(data).find({ plan_key: planKey });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Every inter-epic edge whose dependent is in `planKeys` — the whole DAG for a submitted plan set.
|
|
214
|
+
* Reads per-key (not a table scan) so it composes with the same equality-filtered data layer the
|
|
215
|
+
* unit tests exercise. Producers outside the set are still returned as edge fields; the set
|
|
216
|
+
* validator (S3) is what rejects an edge naming an unsubmitted epic. */
|
|
217
|
+
export async function planDepsForSet(data: DataLayer, planKeys: string[]): Promise<PlanDep[]> {
|
|
218
|
+
const seen = new Set<string>();
|
|
219
|
+
const out: PlanDep[] = [];
|
|
220
|
+
// De-duplicate the keys first so a repeated key (retries / accidental repeats) does not trigger a
|
|
221
|
+
// redundant per-key inbound read; the edge de-dup below still guards against any overlap.
|
|
222
|
+
for (const key of new Set(planKeys)) {
|
|
223
|
+
for (const edge of await inboundPlanDeps(data, key)) {
|
|
224
|
+
const id = `${edge.plan_key}\u0000${edge.depends_on_plan_key}`;
|
|
225
|
+
if (seen.has(id)) continue;
|
|
226
|
+
seen.add(id);
|
|
227
|
+
out.push(edge);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
141
233
|
/** One adversarial plan-review round (006_plan_review.sql): the `senior:plan-review` agent's
|
|
142
234
|
* verdict on the plan before fan-out. Append-only within a plan run; the current round is
|
|
143
235
|
* `count(plan_reviews)`. Re-planning a finished issue clears the prior rows (see startPlan) so
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Red/green regression for the INTER-epic dependency edge (PlanDep) data layer — issue #292 slice S1.
|
|
2
|
+
//
|
|
3
|
+
// This slice adds `plan_deps` (db/migrations/041_inter_epic_plan_deps.sql) and its typed read/write
|
|
4
|
+
// surface in app/plan.ts, mirroring the intra-epic `plan_task_deps` accessors. The durable table
|
|
5
|
+
// enforces "one edge per consumer→producer pair" (PRIMARY KEY) and "no self-edge" (CHECK); these
|
|
6
|
+
// tests pin the app-layer accessors that admission (S2) and the planner (S3) build on, driven against
|
|
7
|
+
// the same in-memory data layer the rest of app/plan's tests use.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
10
|
+
import {
|
|
11
|
+
inboundPlanDeps,
|
|
12
|
+
type PlanDep,
|
|
13
|
+
planDepsForSet,
|
|
14
|
+
recordPlanDep,
|
|
15
|
+
} from "./plan.ts";
|
|
16
|
+
|
|
17
|
+
// Minimal in-memory data layer, matching the helper style in app/plan.test.ts: equality-filtered
|
|
18
|
+
// `find`, append `insert`, and a `delete(planKey)` that clears every row keyed on `plan_key` (so a
|
|
19
|
+
// re-seed of a plan's inbound edge set is one delete, exactly as `plan_task_deps` is cleared).
|
|
20
|
+
function memData() {
|
|
21
|
+
const rows: any[] = [];
|
|
22
|
+
const key = "plan_key";
|
|
23
|
+
const table = {
|
|
24
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
25
|
+
find: (q: any) =>
|
|
26
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
27
|
+
insert: (r: any) => {
|
|
28
|
+
rows.push(r);
|
|
29
|
+
return Promise.resolve(r);
|
|
30
|
+
},
|
|
31
|
+
count: (q: any) =>
|
|
32
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
33
|
+
delete: (k: any) => {
|
|
34
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
rows,
|
|
40
|
+
data: { table: () => table } as any,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EDGE = {
|
|
45
|
+
plan_key: "owner/repo#2",
|
|
46
|
+
depends_on_plan_key: "owner/repo#1",
|
|
47
|
+
package: "@nanobpm/producer",
|
|
48
|
+
capability_ref: "owner/repo#1",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
test("recordPlanDep persists an edge with a stamped created_at", async () => {
|
|
52
|
+
const { data, rows } = memData();
|
|
53
|
+
const row = await recordPlanDep(data, EDGE);
|
|
54
|
+
assertEquals(rows.length, 1);
|
|
55
|
+
assertEquals(row.plan_key, "owner/repo#2");
|
|
56
|
+
assertEquals(row.depends_on_plan_key, "owner/repo#1");
|
|
57
|
+
assertEquals(row.package, "@nanobpm/producer");
|
|
58
|
+
assertEquals(row.capability_ref, "owner/repo#1");
|
|
59
|
+
assertEquals(typeof row.created_at, "string");
|
|
60
|
+
assertEquals(row.created_at.length > 0, true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("recordPlanDep rejects a self-edge (an epic cannot depend on itself)", async () => {
|
|
64
|
+
const { data, rows } = memData();
|
|
65
|
+
await assertRejects(() =>
|
|
66
|
+
recordPlanDep(data, { ...EDGE, plan_key: "owner/repo#1", depends_on_plan_key: "owner/repo#1" }),
|
|
67
|
+
);
|
|
68
|
+
assertEquals(rows.length, 0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("recordPlanDep is idempotent on a duplicate edge (no second row)", async () => {
|
|
72
|
+
const { data, rows } = memData();
|
|
73
|
+
const first = await recordPlanDep(data, EDGE);
|
|
74
|
+
const again = await recordPlanDep(data, { ...EDGE, package: "@nanobpm/ignored-on-dupe" });
|
|
75
|
+
assertEquals(rows.length, 1);
|
|
76
|
+
// The existing row wins — a re-submission does not overwrite nor append.
|
|
77
|
+
assertEquals(again.created_at, first.created_at);
|
|
78
|
+
assertEquals(again.package, "@nanobpm/producer");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recordPlanDep treats a concurrent PK collision as idempotent (no throw)", async () => {
|
|
82
|
+
// Simulate the check-then-insert race the durable composite PRIMARY KEY backstops: a sibling
|
|
83
|
+
// caller wins between our find and our insert, so `find` sees nothing but `insert` collides.
|
|
84
|
+
const rows: any[] = [];
|
|
85
|
+
const key = "plan_key";
|
|
86
|
+
let raceArmed = true;
|
|
87
|
+
const table = {
|
|
88
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
89
|
+
find: (q: any) =>
|
|
90
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
91
|
+
insert: (r: any) => {
|
|
92
|
+
// On the first insert, a concurrent writer has already landed the same pair: append the
|
|
93
|
+
// rival row and reject this one with a UNIQUE/PK constraint error, as SQLite would.
|
|
94
|
+
if (raceArmed) {
|
|
95
|
+
raceArmed = false;
|
|
96
|
+
rows.push({ ...r, package: "@nanobpm/winner", created_at: "1999-01-01T00:00:00.000Z" });
|
|
97
|
+
return Promise.reject(new Error("UNIQUE constraint failed: plan_deps.plan_key"));
|
|
98
|
+
}
|
|
99
|
+
rows.push(r);
|
|
100
|
+
return Promise.resolve(r);
|
|
101
|
+
},
|
|
102
|
+
count: (q: any) =>
|
|
103
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
104
|
+
delete: (k: any) => {
|
|
105
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const data = { table: () => table } as any;
|
|
110
|
+
|
|
111
|
+
const row = await recordPlanDep(data, EDGE);
|
|
112
|
+
// The rival row is returned rather than the constraint error surfacing, and no duplicate lands.
|
|
113
|
+
assertEquals(rows.length, 1);
|
|
114
|
+
assertEquals(row.package, "@nanobpm/winner");
|
|
115
|
+
assertEquals(row.created_at, "1999-01-01T00:00:00.000Z");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("recordPlanDep re-raises a non-collision insert failure", async () => {
|
|
119
|
+
// A genuine failure (the pair is still absent after the re-read) must not be swallowed.
|
|
120
|
+
const table = {
|
|
121
|
+
get: () => Promise.resolve(null),
|
|
122
|
+
find: () => Promise.resolve([] as any[]),
|
|
123
|
+
insert: () => Promise.reject(new Error("disk I/O error")),
|
|
124
|
+
count: () => Promise.resolve(0),
|
|
125
|
+
delete: () => Promise.resolve(),
|
|
126
|
+
};
|
|
127
|
+
const data = { table: () => table } as any;
|
|
128
|
+
await assertRejects(() => recordPlanDep(data, EDGE), Error, "disk I/O error");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("inboundPlanDeps returns every producer a dependent waits on; empty for a root", async () => {
|
|
132
|
+
const { data } = memData();
|
|
133
|
+
await recordPlanDep(data, EDGE);
|
|
134
|
+
await recordPlanDep(data, { ...EDGE, depends_on_plan_key: "owner/repo#3", capability_ref: "owner/repo#3" });
|
|
135
|
+
|
|
136
|
+
const inbound = await inboundPlanDeps(data, "owner/repo#2");
|
|
137
|
+
assertEquals(inbound.length, 2);
|
|
138
|
+
assertEquals(
|
|
139
|
+
inbound.map((e: PlanDep) => e.depends_on_plan_key).sort(),
|
|
140
|
+
["owner/repo#1", "owner/repo#3"],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const root = await inboundPlanDeps(data, "owner/repo#1");
|
|
144
|
+
assertEquals(root.length, 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("planDepsForSet returns the whole DAG for a submitted set, de-duplicated", async () => {
|
|
148
|
+
const { data } = memData();
|
|
149
|
+
// #3 -> #1, #3 -> #2, #2 -> #1 : a small DAG across three epics.
|
|
150
|
+
await recordPlanDep(data, {
|
|
151
|
+
plan_key: "owner/repo#3",
|
|
152
|
+
depends_on_plan_key: "owner/repo#1",
|
|
153
|
+
package: "@nanobpm/a",
|
|
154
|
+
capability_ref: "owner/repo#1",
|
|
155
|
+
});
|
|
156
|
+
await recordPlanDep(data, {
|
|
157
|
+
plan_key: "owner/repo#3",
|
|
158
|
+
depends_on_plan_key: "owner/repo#2",
|
|
159
|
+
package: "@nanobpm/b",
|
|
160
|
+
capability_ref: "owner/repo#2",
|
|
161
|
+
});
|
|
162
|
+
await recordPlanDep(data, {
|
|
163
|
+
plan_key: "owner/repo#2",
|
|
164
|
+
depends_on_plan_key: "owner/repo#1",
|
|
165
|
+
package: "@nanobpm/a",
|
|
166
|
+
capability_ref: "owner/repo#1",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const edges = await planDepsForSet(data, ["owner/repo#1", "owner/repo#2", "owner/repo#3"]);
|
|
170
|
+
assertEquals(edges.length, 3);
|
|
171
|
+
// A root (#1) contributes no inbound edges; passing overlapping keys never double-counts an edge.
|
|
172
|
+
const overlapped = await planDepsForSet(data, ["owner/repo#3", "owner/repo#3"]);
|
|
173
|
+
assertEquals(overlapped.length, 2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("planDepsForSet reads each key once even when planKeys repeats", async () => {
|
|
177
|
+
// Prove the key de-dup: a repeated key must not drive a redundant inbound read.
|
|
178
|
+
const findKeys: string[] = [];
|
|
179
|
+
const table = {
|
|
180
|
+
get: () => Promise.resolve(null),
|
|
181
|
+
find: (q: any) => {
|
|
182
|
+
findKeys.push(q.plan_key);
|
|
183
|
+
return Promise.resolve([] as any[]);
|
|
184
|
+
},
|
|
185
|
+
insert: (r: any) => Promise.resolve(r),
|
|
186
|
+
count: () => Promise.resolve(0),
|
|
187
|
+
delete: () => Promise.resolve(),
|
|
188
|
+
};
|
|
189
|
+
const data = { table: () => table } as any;
|
|
190
|
+
await planDepsForSet(data, ["owner/repo#3", "owner/repo#3", "owner/repo#4", "owner/repo#3"]);
|
|
191
|
+
assertEquals(findKeys.sort(), ["owner/repo#3", "owner/repo#4"]);
|
|
192
|
+
});
|
|
@@ -51,12 +51,22 @@ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: R
|
|
|
51
51
|
return { data, stores };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** A
|
|
55
|
-
*
|
|
56
|
-
|
|
54
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
55
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
56
|
+
* A looping instance holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
57
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
58
|
+
|
|
59
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field the poller queries on
|
|
60
|
+
* for plan / PR instances). It models the real engine's two accessors from ONE fixture so a test
|
|
61
|
+
* genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
62
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
63
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
64
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
65
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
57
66
|
return {
|
|
58
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
59
|
-
|
|
67
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
68
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
69
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
60
70
|
} as unknown as EngineClient;
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -172,3 +182,50 @@ test("pollUserTasks: skips terminal plans and PRs without a process key", async
|
|
|
172
182
|
|
|
173
183
|
assertEquals(stores.user_tasks ?? [], []);
|
|
174
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── Defect-class guard (issue #294): a looping instance holds MULTIPLE tasks for one element ───────
|
|
187
|
+
// The plan-review (review→revise→review) and PR-wait (escalate→answer→re-escalate) elements sit on a
|
|
188
|
+
// loop, so a looping instance holds a COMPLETED task from a prior round alongside the live CREATED one,
|
|
189
|
+
// and the engine returns the COMPLETED one first. Scoping the query to open (CREATED) tasks projects
|
|
190
|
+
// only the live completable key onto `user_tasks`, never a terminal one the page could not complete.
|
|
191
|
+
test("pollUserTasks: a looping plan/PR projects only the CREATED task, never the COMPLETED one", async () => {
|
|
192
|
+
const { data, stores } = memData({
|
|
193
|
+
plans: [{ plan_key: "o/r#50", status: "dispatched", process_key: "pp-50", issue_url: "https://github.com/o/r/issues/50" }],
|
|
194
|
+
plan_reviews: [{ plan_key: "o/r#50", epoch: 0, round: 0, approved: 0, findings: "scope too broad", created_at: "2025-01-01T00:00:00.000Z" }],
|
|
195
|
+
pull_requests: [{ pr_key: "o/r#51", status: "escalated", process_key: "rp-51", url: "https://github.com/o/r/pull/51" }],
|
|
196
|
+
escalations: [{ id: 1, pr_key: "o/r#51", status: "open", question: "conflicting reviews" }],
|
|
197
|
+
});
|
|
198
|
+
// Each looping instance returns its COMPLETED prior-round task FIRST, then the live CREATED one.
|
|
199
|
+
const engine = fakeEngine({
|
|
200
|
+
"pp-50": [
|
|
201
|
+
{ userTaskKey: "ut-plan-completed", elementId: "plan-review-decision", state: "COMPLETED" },
|
|
202
|
+
{ userTaskKey: "ut-plan-live", elementId: "plan-review-decision", state: "CREATED" },
|
|
203
|
+
],
|
|
204
|
+
"rp-51": [
|
|
205
|
+
{ userTaskKey: "ut-pr-completed", elementId: "wait-answer", state: "COMPLETED" },
|
|
206
|
+
{ userTaskKey: "ut-pr-live", elementId: "wait-answer", state: "CREATED" },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pollUserTasks(data, engine);
|
|
211
|
+
|
|
212
|
+
const keys = (stores.user_tasks ?? []).map((r) => r.user_task_key).sort();
|
|
213
|
+
// Only the live CREATED keys — the COMPLETED prior-round tasks must never surface a dead affordance.
|
|
214
|
+
assertEquals(keys, ["ut-plan-live", "ut-pr-live"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Self-heal reached: an instance whose only task for an element is COMPLETED yields no open task, so
|
|
218
|
+
// its row is removed (open-task query returns []), rather than pinning a dead completable pointer.
|
|
219
|
+
test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row", async () => {
|
|
220
|
+
const { data, stores } = memData({
|
|
221
|
+
plans: [{ plan_key: "o/r#52", status: "dispatched", process_key: "pp-52", issue_url: "https://github.com/o/r/issues/52" }],
|
|
222
|
+
plan_reviews: [{ plan_key: "o/r#52", epoch: 0, round: 0, approved: 0, findings: "scope too broad", created_at: "2025-01-01T00:00:00.000Z" }],
|
|
223
|
+
});
|
|
224
|
+
const engine = fakeEngine({
|
|
225
|
+
"pp-52": [{ userTaskKey: "ut-plan-completed", elementId: "plan-review-decision", state: "COMPLETED" }],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await pollUserTasks(data, engine);
|
|
229
|
+
|
|
230
|
+
assertEquals(stores.user_tasks ?? [], []);
|
|
231
|
+
});
|
|
@@ -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
|
+
}
|