@nanobpm/nano-workforce 0.74.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/feature.test.ts +63 -1
- package/app/feature.ts +16 -0
- package/app/github.test.ts +77 -1
- package/app/github.ts +44 -0
- package/app/plan.test.ts +90 -1
- package/app/plan.ts +12 -1
- package/app/service.ts +9 -2
- package/db/migrations/035_feature_runs_title.sql +17 -0
- package/db/migrations/036_backfill_titles.sql +18 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/pages/home.page.json +3 -2
- package/pages/overview.page.json +5 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.75.0](https://github.com/nanobpm/nano-workforce/compare/v0.74.0...v0.75.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* surface issue/PR titles on dispatch surfaces ([#248](https://github.com/nanobpm/nano-workforce/issues/248)) ([#249](https://github.com/nanobpm/nano-workforce/issues/249)) ([a63f6dd](https://github.com/nanobpm/nano-workforce/commit/a63f6dd9bab3040cdc61c807bdc999ce9a591299))
|
|
7
|
+
|
|
1
8
|
# [0.74.0](https://github.com/nanobpm/nano-workforce/compare/v0.73.1...v0.74.0) (2026-08-16)
|
|
2
9
|
|
|
3
10
|
|
package/app/feature.test.ts
CHANGED
|
@@ -5,10 +5,27 @@
|
|
|
5
5
|
// drive it against an in-memory data layer + a stub engine and assert the row shape, the
|
|
6
6
|
// short-circuit on an already-running run, the in-place restart of a settled run, and the seeded
|
|
7
7
|
// process variables (the single `task` slice + the base-branch brief).
|
|
8
|
-
import { test } from "node:test";
|
|
8
|
+
import { after, test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
10
|
import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
|
|
11
11
|
|
|
12
|
+
// `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token
|
|
13
|
+
// transport with no token so the fetch is a hermetic no-op (returns null) — no `gh` subprocess, no
|
|
14
|
+
// network — and the row `title` deterministically coalesces to the `owner/repo#N` key. A dedicated
|
|
15
|
+
// test below stubs a successful fetch to cover the real-title path. Capture the prior values and
|
|
16
|
+
// restore them after this file's tests so the module-scope mutation never leaks into other test
|
|
17
|
+
// files under concurrent `node --test`.
|
|
18
|
+
const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
19
|
+
const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
|
|
20
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
21
|
+
delete process.env["GITHUB_TOKEN"];
|
|
22
|
+
after(() => {
|
|
23
|
+
if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
24
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
|
|
25
|
+
if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
|
|
26
|
+
else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
|
|
27
|
+
});
|
|
28
|
+
|
|
12
29
|
function memTable(rows: any[], key: string) {
|
|
13
30
|
return {
|
|
14
31
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
@@ -221,3 +238,48 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
|
|
|
221
238
|
assertEquals(row.auto_merge, 1);
|
|
222
239
|
assertEquals(row.process_key, "PI-2");
|
|
223
240
|
});
|
|
241
|
+
|
|
242
|
+
// Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
|
|
243
|
+
// `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
|
|
244
|
+
// (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
|
|
245
|
+
test("startFeature: coalesces title to the key when the fetch yields nothing (insert path)", async () => {
|
|
246
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
247
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any;
|
|
248
|
+
await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
249
|
+
assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("startFeature: repopulates a non-blank title on the in-place restart (update path)", async () => {
|
|
253
|
+
const stores = {
|
|
254
|
+
feature_runs: {
|
|
255
|
+
rows: [{ feature_key: "owner/repo#42", status: "opened", title: null, process_key: "PI-OLD" }],
|
|
256
|
+
key: "feature_key",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any;
|
|
260
|
+
await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
261
|
+
assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("startFeature: persists the real issue title when the fetch succeeds", async () => {
|
|
265
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
266
|
+
const prevFetch = globalThis.fetch;
|
|
267
|
+
process.env["GITHUB_TOKEN"] = "t0ken";
|
|
268
|
+
globalThis.fetch = ((url: string | URL | Request) => {
|
|
269
|
+
const u = String(url);
|
|
270
|
+
if (u.endsWith("/repos/owner/repo/issues/42")) {
|
|
271
|
+
return Promise.resolve(new Response(JSON.stringify({ title: "Add the widget" }), { status: 200 }));
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
274
|
+
}) as typeof fetch;
|
|
275
|
+
try {
|
|
276
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
277
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any;
|
|
278
|
+
await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
279
|
+
assertEquals(stores.feature_runs.rows[0].title, "Add the widget");
|
|
280
|
+
} finally {
|
|
281
|
+
globalThis.fetch = prevFetch;
|
|
282
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
283
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
284
|
+
}
|
|
285
|
+
});
|
package/app/feature.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Data access goes through the record gateway (`data.table`), never hand-written
|
|
16
16
|
// SQL — matching app/plan.ts and app/service.ts.
|
|
17
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
|
+
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
18
19
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
19
20
|
|
|
20
21
|
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
@@ -32,6 +33,11 @@ export interface FeatureRun {
|
|
|
32
33
|
repo: string;
|
|
33
34
|
issue_number: number;
|
|
34
35
|
issue_url: string;
|
|
36
|
+
/** Human-readable identity (the GitHub issue title) for the feature grids (issue #248). Fetched
|
|
37
|
+
* best-effort at `startFeature` and coalesced to the `owner/repo#N` key at write time, so it is
|
|
38
|
+
* ALWAYS non-blank — the grid's `{{title}}` template needs no fallback and a failed/absent title
|
|
39
|
+
* fetch still shows a usable identity (the key) rather than an empty cell. */
|
|
40
|
+
title: string | null;
|
|
35
41
|
base_branch: string;
|
|
36
42
|
status: FeatureRunStatus;
|
|
37
43
|
process_key: string | null;
|
|
@@ -282,11 +288,20 @@ export async function startFeature(
|
|
|
282
288
|
}
|
|
283
289
|
const base = normalizeBaseBranch(baseBranch);
|
|
284
290
|
const ts = now();
|
|
291
|
+
// Human-readable identity for the feature grids (issue #248): fetch the issue title best-effort
|
|
292
|
+
// and coalesce to the `owner/repo#N` key so `feature_runs.title` is ALWAYS non-blank (see the
|
|
293
|
+
// interface note); a blank/whitespace fetch counts as missing. A fetch failure never blocks the
|
|
294
|
+
// start (`fetchIssueTitle` returns null on any error).
|
|
295
|
+
const title = coalesceTitle(
|
|
296
|
+
await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
|
|
297
|
+
parsed.planKey,
|
|
298
|
+
);
|
|
285
299
|
if (existing) {
|
|
286
300
|
await table.update(parsed.planKey, {
|
|
287
301
|
status: "running",
|
|
288
302
|
base_branch: base,
|
|
289
303
|
issue_url: parsed.url,
|
|
304
|
+
title,
|
|
290
305
|
pr_key: null,
|
|
291
306
|
converge: converge ? 1 : 0,
|
|
292
307
|
auto_merge: autoMerge ? 1 : 0,
|
|
@@ -303,6 +318,7 @@ export async function startFeature(
|
|
|
303
318
|
repo: parsed.repo,
|
|
304
319
|
issue_number: parsed.number,
|
|
305
320
|
issue_url: parsed.url,
|
|
321
|
+
title,
|
|
306
322
|
base_branch: base,
|
|
307
323
|
status: "running",
|
|
308
324
|
process_key: null,
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, coalesceTitle, ensureBaseBranch, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
|
|
7
7
|
|
|
8
8
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
9
9
|
// files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
|
|
@@ -259,3 +259,79 @@ test("isNotAPullRequestError: transient failures stay blocking (false)", () => {
|
|
|
259
259
|
assertEquals(isNotAPullRequestError(new Error("fetch failed")), false);
|
|
260
260
|
assertEquals(isNotAPullRequestError(null), false);
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
// Issue #248: `fetchIssueTitle` labels the epics/feature rows with the real GitHub issue title. It
|
|
264
|
+
// is best-effort and MUST be tolerant of failure (returns null, never throws) so a title fetch can
|
|
265
|
+
// never block an epic/feature start — the caller falls back to the `owner/repo#N` key.
|
|
266
|
+
function withTitleFetch<T>(
|
|
267
|
+
fetchImpl: typeof globalThis.fetch,
|
|
268
|
+
token: string | undefined,
|
|
269
|
+
fn: () => Promise<T>,
|
|
270
|
+
): Promise<T> {
|
|
271
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
272
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
273
|
+
const prevFetch = globalThis.fetch;
|
|
274
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
275
|
+
if (token === undefined) delete process.env["GITHUB_TOKEN"];
|
|
276
|
+
else process.env["GITHUB_TOKEN"] = token;
|
|
277
|
+
globalThis.fetch = fetchImpl;
|
|
278
|
+
const restore = () => {
|
|
279
|
+
globalThis.fetch = prevFetch;
|
|
280
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
281
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
282
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
283
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
284
|
+
};
|
|
285
|
+
return fn().finally(restore);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
test("fetchIssueTitle: returns the title on a successful token-transport read", async () => {
|
|
289
|
+
const stub = ((url: string | URL | Request) => {
|
|
290
|
+
assertEquals(String(url).endsWith("/repos/owner/repo/issues/248"), true);
|
|
291
|
+
return Promise.resolve(new Response(JSON.stringify({ title: "Surface titles" }), { status: 200 }));
|
|
292
|
+
}) as typeof fetch;
|
|
293
|
+
const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 248, "t0ken"));
|
|
294
|
+
assertEquals(title, "Surface titles");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("fetchIssueTitle: a non-2xx response yields null (best-effort, never throws)", async () => {
|
|
298
|
+
const stub = (() => Promise.resolve(new Response("nope", { status: 404 }))) as typeof fetch;
|
|
299
|
+
const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 999, "t0ken"));
|
|
300
|
+
assertEquals(title, null);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("fetchIssueTitle: a thrown transport error is swallowed to null", async () => {
|
|
304
|
+
const stub = (() => Promise.reject(new Error("network down"))) as typeof fetch;
|
|
305
|
+
const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 7, "t0ken"));
|
|
306
|
+
assertEquals(title, null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("fetchIssueTitle: token mode with no token is a no-op (null)", async () => {
|
|
310
|
+
const stub = (() => {
|
|
311
|
+
throw new Error("fetch must not be called without a token");
|
|
312
|
+
}) as typeof fetch;
|
|
313
|
+
const title = await withTitleFetch(stub, undefined, () => fetchIssueTitle("owner/repo", 7, ""));
|
|
314
|
+
assertEquals(title, null);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Issue #248: `coalesceTitle` guarantees a non-blank identity for the title-led grids. A best-effort
|
|
318
|
+
// fetch can legitimately return "" (or whitespace) — `??` would persist that blank; `coalesceTitle`
|
|
319
|
+
// treats it as missing (matching the 036 backfill's `trim(title) = ''`) and falls back to the key.
|
|
320
|
+
test("coalesceTitle: a non-blank first candidate wins", () => {
|
|
321
|
+
assertEquals(coalesceTitle("Real title", "owner/repo#1"), "Real title");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("coalesceTitle: null/undefined candidates fall through to the key", () => {
|
|
325
|
+
assertEquals(coalesceTitle(null, "owner/repo#1"), "owner/repo#1");
|
|
326
|
+
assertEquals(coalesceTitle(undefined, "owner/repo#1"), "owner/repo#1");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("coalesceTitle: a blank/whitespace title counts as missing", () => {
|
|
330
|
+
assertEquals(coalesceTitle("", "owner/repo#1"), "owner/repo#1");
|
|
331
|
+
assertEquals(coalesceTitle(" ", "owner/repo#1"), "owner/repo#1");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("coalesceTitle: skips a blank middle candidate to the next non-blank one", () => {
|
|
335
|
+
assertEquals(coalesceTitle("", "Prior title", "owner/repo#1"), "Prior title");
|
|
336
|
+
assertEquals(coalesceTitle(null, " ", "owner/repo#1"), "owner/repo#1");
|
|
337
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -429,6 +429,50 @@ export async function fetchPrMeta(
|
|
|
429
429
|
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
/** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
|
|
433
|
+
* token transports). Best-effort and tolerant of failure: returns `null` when no transport is
|
|
434
|
+
* usable OR when the fetch fails/returns no title, so a caller can label a row with the real issue
|
|
435
|
+
* title on success and fall back to the `owner/repo#N` key otherwise — a title fetch must never
|
|
436
|
+
* block an epic/feature start. Unlike the merge-stage reads it does NOT throw on a transport
|
|
437
|
+
* failure; the identity it feeds is cosmetic, not a correctness gate. */
|
|
438
|
+
export async function fetchIssueTitle(
|
|
439
|
+
repo: string,
|
|
440
|
+
number: number | string,
|
|
441
|
+
token: string,
|
|
442
|
+
): Promise<string | null> {
|
|
443
|
+
try {
|
|
444
|
+
if (await useGh()) {
|
|
445
|
+
const out = await runGh(["issue", "view", String(number), "--repo", repo, "--json", "title"]);
|
|
446
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
447
|
+
const j = JSON.parse(out) as { title?: string };
|
|
448
|
+
return j.title ?? null;
|
|
449
|
+
}
|
|
450
|
+
if (!token) return null; // token mode with no token → no identity to fetch (caller falls back to the key)
|
|
451
|
+
const r = await fetch(`https://api.github.com/repos/${repo}/issues/${number}`, {
|
|
452
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
453
|
+
});
|
|
454
|
+
if (!r.ok) return null;
|
|
455
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
456
|
+
const j = (await r.json()) as { title?: string };
|
|
457
|
+
return j.title ?? null;
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Coalesce best-effort title candidates to a non-blank identity for the title-led grids (issue
|
|
464
|
+
* #248). A candidate that is null/undefined OR blank/whitespace-only is treated as missing —
|
|
465
|
+
* external data (`fetchIssueTitle`/`fetchPrMeta`) can legitimately return `""`, which `??` would
|
|
466
|
+
* wrongly persist as a blank identity cell. Returns the first non-blank candidate, else the last
|
|
467
|
+
* one (the caller's key fallback, which is always non-blank). Mirrors the 036 backfill's
|
|
468
|
+
* `trim(title) = ''` test so write-time and backfill agree. */
|
|
469
|
+
export function coalesceTitle(...candidates: (string | null | undefined)[]): string {
|
|
470
|
+
for (const c of candidates) {
|
|
471
|
+
if (c != null && c.trim() !== "") return c;
|
|
472
|
+
}
|
|
473
|
+
return candidates[candidates.length - 1] ?? "";
|
|
474
|
+
}
|
|
475
|
+
|
|
432
476
|
/** A PR's merge state, narrowed to what the merge poller needs to classify landability.
|
|
433
477
|
* `mergeStateStatus` uses GitHub's vocabulary (CLEAN | BLOCKED | BEHIND | DIRTY | UNSTABLE |
|
|
434
478
|
* DRAFT | HAS_HOOKS | UNKNOWN). `failingChecks` is `-1` when the transport can't enumerate
|
package/app/plan.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `NaN`/`0` (e.g. unset, "", "abc"), the cap check `round + 1 >= cap` would never fire and the
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
|
-
import { test } from "node:test";
|
|
7
|
+
import { after, test } from "node:test";
|
|
8
8
|
import { assertEquals, assertRejects, assertThrows } from "#test-assert";
|
|
9
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
10
|
|
|
@@ -59,6 +59,23 @@ test("valid positive integer → honoured", () => {
|
|
|
59
59
|
// asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
|
|
60
60
|
import { startPlan } from "./plan.ts";
|
|
61
61
|
|
|
62
|
+
// `startPlan` now fetches the epic issue title (issue #248) via the GitHub transport. Force the
|
|
63
|
+
// token transport with no token so the fetch is a hermetic no-op (returns null) and the row `title`
|
|
64
|
+
// deterministically coalesces to the `owner/repo#N` key — no `gh` subprocess, no network. A
|
|
65
|
+
// dedicated test below stubs a successful fetch to cover the real-title path. Capture the prior
|
|
66
|
+
// values and restore them after this file's tests so the module-scope mutation never leaks into
|
|
67
|
+
// other test files under concurrent `node --test`.
|
|
68
|
+
const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
69
|
+
const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
|
|
70
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
71
|
+
delete process.env["GITHUB_TOKEN"];
|
|
72
|
+
after(() => {
|
|
73
|
+
if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
74
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
|
|
75
|
+
if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
|
|
76
|
+
else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
|
|
77
|
+
});
|
|
78
|
+
|
|
62
79
|
function memTable(rows: any[], key: string) {
|
|
63
80
|
return {
|
|
64
81
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
@@ -499,3 +516,75 @@ test("findActivePlansByBase returns only non-terminal plans on the matching repo
|
|
|
499
516
|
assertEquals(active.length, 1);
|
|
500
517
|
assertEquals(active[0].plan_key, "o/x#1");
|
|
501
518
|
});
|
|
519
|
+
|
|
520
|
+
// Issue #248: the human-readable identity for the epics grids. `startPlan` persists a non-blank
|
|
521
|
+
// `plans.title` on BOTH the insert (new epic) and update (re-plan) paths — the fetched issue title
|
|
522
|
+
// when available, else the `owner/repo#N` key — so the title-led grid never renders a blank cell.
|
|
523
|
+
test("startPlan coalesces title to the key when the fetch yields nothing (insert path)", async () => {
|
|
524
|
+
const PLAN_KEY = "owner/repo#248";
|
|
525
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
526
|
+
plans: { rows: [], key: "plan_key" },
|
|
527
|
+
plan_tasks: { rows: [], key: "id" },
|
|
528
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
529
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
530
|
+
};
|
|
531
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any;
|
|
532
|
+
await startPlan(memData(stores), engine, {
|
|
533
|
+
repo: "owner/repo",
|
|
534
|
+
number: 248,
|
|
535
|
+
url: "https://github.com/owner/repo/issues/248",
|
|
536
|
+
planKey: PLAN_KEY,
|
|
537
|
+
}, "main");
|
|
538
|
+
assertEquals((stores.plans.rows[0] as any).title, PLAN_KEY);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("startPlan repopulates a non-blank title on re-plan (update path)", async () => {
|
|
542
|
+
const PLAN_KEY = "owner/repo#249";
|
|
543
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
544
|
+
plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, title: null }], key: "plan_key" },
|
|
545
|
+
plan_tasks: { rows: [], key: "id" },
|
|
546
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
547
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
548
|
+
};
|
|
549
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any;
|
|
550
|
+
await startPlan(memData(stores), engine, {
|
|
551
|
+
repo: "owner/repo",
|
|
552
|
+
number: 249,
|
|
553
|
+
url: "https://github.com/owner/repo/issues/249",
|
|
554
|
+
planKey: PLAN_KEY,
|
|
555
|
+
}, "main");
|
|
556
|
+
assertEquals((stores.plans.rows[0] as any).title, PLAN_KEY);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("startPlan persists the real epic issue title when the fetch succeeds", async () => {
|
|
560
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
561
|
+
const prevFetch = globalThis.fetch;
|
|
562
|
+
process.env["GITHUB_TOKEN"] = "t0ken";
|
|
563
|
+
globalThis.fetch = ((url: string | URL | Request) => {
|
|
564
|
+
const u = String(url);
|
|
565
|
+
if (u.endsWith("/repos/owner/repo/issues/250")) {
|
|
566
|
+
return Promise.resolve(new Response(JSON.stringify({ title: "Ship the epic" }), { status: 200 }));
|
|
567
|
+
}
|
|
568
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
569
|
+
}) as typeof fetch;
|
|
570
|
+
try {
|
|
571
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
572
|
+
plans: { rows: [], key: "plan_key" },
|
|
573
|
+
plan_tasks: { rows: [], key: "id" },
|
|
574
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
575
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
576
|
+
};
|
|
577
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any;
|
|
578
|
+
await startPlan(memData(stores), engine, {
|
|
579
|
+
repo: "owner/repo",
|
|
580
|
+
number: 250,
|
|
581
|
+
url: "https://github.com/owner/repo/issues/250",
|
|
582
|
+
planKey: "owner/repo#250",
|
|
583
|
+
}, "main");
|
|
584
|
+
assertEquals((stores.plans.rows[0] as any).title, "Ship the epic");
|
|
585
|
+
} finally {
|
|
586
|
+
globalThis.fetch = prevFetch;
|
|
587
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
588
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
589
|
+
}
|
|
590
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
14
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
15
|
-
import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
|
|
15
|
+
import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
|
|
16
16
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
17
17
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
18
18
|
|
|
@@ -400,6 +400,15 @@ export async function startPlan(
|
|
|
400
400
|
}
|
|
401
401
|
const base = normalizeBaseBranch(baseBranch);
|
|
402
402
|
const ts = now();
|
|
403
|
+
// Human-readable identity for the epics grids (issue #248): fetch the epic issue's title,
|
|
404
|
+
// best-effort. Coalesce to the `owner/repo#N` key at write time so `plans.title` is ALWAYS
|
|
405
|
+
// non-blank — the grid's `{{title}}` template then needs no fallback, and a failed/absent/blank
|
|
406
|
+
// fetch still shows a usable identity (the key) rather than an empty cell. A fetch failure never
|
|
407
|
+
// blocks the start (`fetchIssueTitle` returns null on any error).
|
|
408
|
+
const title = coalesceTitle(
|
|
409
|
+
await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
|
|
410
|
+
parsed.planKey,
|
|
411
|
+
);
|
|
403
412
|
// Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
|
|
404
413
|
// coordination brief that carries its concrete URL. The token is the credential; agents reach
|
|
405
414
|
// the blackboard directly with the URL we seed into `appendPrompt` below (#51).
|
|
@@ -425,6 +434,7 @@ export async function startPlan(
|
|
|
425
434
|
status: "planning",
|
|
426
435
|
task_count: 0,
|
|
427
436
|
issue_url: parsed.url,
|
|
437
|
+
title,
|
|
428
438
|
outcome: null,
|
|
429
439
|
blackboard_token: token,
|
|
430
440
|
base_branch: base,
|
|
@@ -436,6 +446,7 @@ export async function startPlan(
|
|
|
436
446
|
repo: parsed.repo,
|
|
437
447
|
issue_number: parsed.number,
|
|
438
448
|
issue_url: parsed.url,
|
|
449
|
+
title,
|
|
439
450
|
status: "planning",
|
|
440
451
|
task_count: 0,
|
|
441
452
|
blackboard_token: token,
|
package/app/service.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { agentSlaTimeout } from "./agentSla.ts";
|
|
|
14
14
|
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
15
15
|
import {
|
|
16
16
|
classifyMergeability,
|
|
17
|
+
coalesceTitle,
|
|
17
18
|
ensureFreshHeadRun,
|
|
18
19
|
fetchPrHead,
|
|
19
20
|
fetchPrMeta,
|
|
@@ -460,7 +461,11 @@ export async function submitPr(
|
|
|
460
461
|
status: "converging",
|
|
461
462
|
current_round: 1,
|
|
462
463
|
url: parsed.url,
|
|
463
|
-
|
|
464
|
+
// Coalesce to the key so `pull_requests.title` stays non-blank for the title-led grids
|
|
465
|
+
// (issue #248): a fresh fetch wins, else the prior title, else the `owner/repo#N` key.
|
|
466
|
+
// A blank/whitespace title counts as missing (matches the 036 backfill), so an empty
|
|
467
|
+
// external title never lands as an unlabeled row.
|
|
468
|
+
title: coalesceTitle(title, existing.title, parsed.prKey),
|
|
464
469
|
waiting_since: null,
|
|
465
470
|
last_review_id: null,
|
|
466
471
|
last_nudge_at: null,
|
|
@@ -476,7 +481,9 @@ export async function submitPr(
|
|
|
476
481
|
repo: parsed.repo,
|
|
477
482
|
number: parsed.number,
|
|
478
483
|
url: parsed.url,
|
|
479
|
-
title
|
|
484
|
+
// Coalesce to the key so the title-led grids never render a blank identity (issue #248);
|
|
485
|
+
// a blank/whitespace external title counts as missing (matches the 036 backfill).
|
|
486
|
+
title: coalesceTitle(title, parsed.prKey),
|
|
480
487
|
status: "converging",
|
|
481
488
|
current_round: 1,
|
|
482
489
|
abandon_token: abandonToken,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Surface the GitHub issue title as the primary, human-readable identity on the
|
|
2
|
+
-- feature grids (issue #248, L0). Every dispatch row was keyed only by
|
|
3
|
+
-- `owner/repo#N` — meaningful while you remember the number, opaque hours later.
|
|
4
|
+
--
|
|
5
|
+
-- `plans.title` (004_planning.sql) and `pull_requests.title` already existed;
|
|
6
|
+
-- `feature_runs` had no title column at all. Add one so `startFeature` can persist
|
|
7
|
+
-- the fetched issue title (best-effort), coalesced to the `owner/repo#N` key at
|
|
8
|
+
-- write time so the column is ALWAYS non-blank — the grid's `{{title}}` template
|
|
9
|
+
-- then needs no fallback and a failed/absent fetch still shows a usable identity.
|
|
10
|
+
--
|
|
11
|
+
-- Forward-only, additive (expand): nullable with no default, so pre-#248 rows
|
|
12
|
+
-- grandfather in as NULL. Write-time coalescing keeps new rows non-blank; the
|
|
13
|
+
-- follow-up 036_backfill_titles.sql then backfills these legacy NULL/blank titles
|
|
14
|
+
-- to the row key, so the title-led grids never render a blank identity cell.
|
|
15
|
+
-- Numbered after the current highest prefix on origin/main (034); the runner wraps
|
|
16
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
17
|
+
ALTER TABLE feature_runs ADD COLUMN title TEXT;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Backfill legacy NULL/blank titles so the title-led grids (issue #248) never
|
|
2
|
+
-- render a blank identity cell for pre-#248 rows.
|
|
3
|
+
--
|
|
4
|
+
-- 035_feature_runs_title.sql added `feature_runs.title` nullable, and both
|
|
5
|
+
-- `plans.title` (004_planning.sql) and `pull_requests.title` (001_init.sql) have
|
|
6
|
+
-- long allowed NULL. The dispatch/overview grids now render `template: "{{title}}"`
|
|
7
|
+
-- directly off these tables with NO key fallback, so any historical row whose title
|
|
8
|
+
-- is NULL or blank shows as an unlabeled row. `startPlan`/`startFeature`/the PR
|
|
9
|
+
-- upsert already coalesce title to the `owner/repo#N` key at write time; this
|
|
10
|
+
-- migration applies the same coalesce once to the rows that predate that behaviour.
|
|
11
|
+
--
|
|
12
|
+
-- Forward-only, additive (data-only): sets title to the row's key where it is
|
|
13
|
+
-- currently NULL or blank. Idempotent — re-running is a no-op once titles are set.
|
|
14
|
+
-- Numbered after the current highest prefix on origin/main (035); the runner wraps
|
|
15
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
16
|
+
UPDATE plans SET title = plan_key WHERE title IS NULL OR trim(title) = '';
|
|
17
|
+
UPDATE feature_runs SET title = feature_key WHERE title IS NULL OR trim(title) = '';
|
|
18
|
+
UPDATE pull_requests SET title = pr_key WHERE title IS NULL OR trim(title) = '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
56
56
|
},
|
|
57
57
|
"columns": [
|
|
58
|
-
{ "field": "
|
|
58
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "linkField": "issue_url" },
|
|
59
59
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
60
60
|
{ "field": "delivery", "header": "Delivery" },
|
|
61
61
|
{ "field": "wave_label", "header": "Wave" },
|
package/pages/epic.page.json
CHANGED
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
{ "label": "All", "filter": [] }
|
|
74
74
|
],
|
|
75
75
|
"columns": [
|
|
76
|
-
{ "field": "
|
|
76
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
77
77
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
78
78
|
{ "field": "delivery", "header": "Delivery" },
|
|
79
79
|
{ "field": "base_branch", "header": "Base branch" },
|
package/pages/feature.page.json
CHANGED
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
{ "label": "All", "filter": [] }
|
|
71
71
|
],
|
|
72
72
|
"columns": [
|
|
73
|
-
{ "field": "
|
|
73
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "linkField": "issue_url" },
|
|
74
74
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
75
75
|
{ "field": "escalation_question", "header": "Escalation" },
|
|
76
76
|
{ "field": "base_branch", "header": "Base branch" },
|
package/pages/home.page.json
CHANGED
package/pages/overview.page.json
CHANGED
|
@@ -63,15 +63,9 @@
|
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
65
|
"columns": [
|
|
66
|
-
{ "field": "
|
|
66
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
67
67
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
68
|
-
{
|
|
69
|
-
"field": "incident_message",
|
|
70
|
-
"header": "Incident",
|
|
71
|
-
"badge": { "tone": "danger", "label": "1" }
|
|
72
|
-
},
|
|
73
|
-
{ "field": "current_round", "header": "Round" },
|
|
74
|
-
{ "field": "active_worker", "header": "Agent" },
|
|
68
|
+
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
75
69
|
{ "field": "updated_at", "header": "Updated" }
|
|
76
70
|
]
|
|
77
71
|
}
|
|
@@ -94,13 +88,9 @@
|
|
|
94
88
|
"filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
|
|
95
89
|
},
|
|
96
90
|
"columns": [
|
|
97
|
-
{ "field": "
|
|
91
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
98
92
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
99
|
-
{ "field": "delivery", "header": "Delivery" },
|
|
100
|
-
{ "field": "base_branch", "header": "Base branch" },
|
|
101
93
|
{ "field": "wave_label", "header": "Wave" },
|
|
102
|
-
{ "field": "task_count", "header": "Tasks" },
|
|
103
|
-
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
104
94
|
{ "field": "updated_at", "header": "Updated" }
|
|
105
95
|
]
|
|
106
96
|
}
|
|
@@ -123,14 +113,9 @@
|
|
|
123
113
|
"filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }]
|
|
124
114
|
},
|
|
125
115
|
"columns": [
|
|
126
|
-
{ "field": "
|
|
116
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
127
117
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
128
|
-
{ "field": "
|
|
129
|
-
{ "field": "base_branch", "header": "Base branch" },
|
|
130
|
-
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
131
|
-
{ "field": "converge", "header": "Converge" },
|
|
132
|
-
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
133
|
-
{ "field": "outcome", "header": "Outcome" },
|
|
118
|
+
{ "field": "delivery_label", "header": "Delivery" },
|
|
134
119
|
{ "field": "updated_at", "header": "Updated" }
|
|
135
120
|
],
|
|
136
121
|
"rowActions": [
|