@nanobpm/nano-workforce 0.35.2 → 0.37.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/.github/workflows/ci.yml +10 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/actions/blackboard.ts +1 -0
- package/actions/feature-answer-hook.ts +1 -0
- package/actions/plan-hook.ts +2 -1
- package/actions/webhook-submit.ts +2 -1
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/blackboard.ts +7 -6
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +24 -22
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeExclusion.ts +8 -3
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeProtocol.ts +1 -0
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +9 -30
- package/app/persist-round.test.ts +6 -19
- package/app/plan.test.ts +19 -42
- package/app/plan.ts +1 -1
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/retro.ts +4 -2
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +8 -4
- package/app/taskDelta.test.ts +8 -17
- package/app/taskDelta.ts +1 -0
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +9 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +143 -0
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +2 -3
- package/operations/postMessage.ts +3 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +9 -4
- package/plugins/no-unsafe-type-assertion.grit +12 -0
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/check-agent-prompts.ts +4 -2
- package/scripts/layout-bpmn.ts +6 -26
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +3 -2
- package/scripts/upgrade-from-pack.ts +9 -5
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/finalize/worker.ts +2 -1
- package/workers/merge/worker.ts +3 -3
- package/workers/persist-escalation/worker.ts +2 -1
- package/workers/persist-round/worker.ts +2 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-plan-review/worker.ts +2 -1
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-results/worker.ts +2 -1
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-trial-merge/worker.ts +6 -6
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/record-wave/worker.ts +7 -8
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/retro-record/worker.ts +3 -3
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1776
package/app/blackboard.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
3
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
5
|
import {
|
|
5
6
|
appendEntry,
|
|
@@ -17,16 +18,12 @@ import {
|
|
|
17
18
|
|
|
18
19
|
// A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
|
|
19
20
|
// blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
21
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
22
|
-
// deno-lint-ignore no-explicit-any
|
|
23
22
|
const stores: Record<string, any[]> = {};
|
|
24
23
|
const seq: Record<string, number> = {};
|
|
25
24
|
function tbl(name: string, pk = "id") {
|
|
26
|
-
// deno-lint-ignore no-explicit-any
|
|
27
25
|
const rows = (stores[name] ??= [] as any[]);
|
|
28
26
|
return {
|
|
29
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
30
27
|
async insert(row: any) {
|
|
31
28
|
if (pk === "id") {
|
|
32
29
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
@@ -36,22 +33,19 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
36
33
|
rows.push({ ...row });
|
|
37
34
|
return row[pk];
|
|
38
35
|
},
|
|
39
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
40
36
|
async find(where: any = {}) {
|
|
41
37
|
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
42
38
|
},
|
|
43
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
44
39
|
async findOne(where: any = {}) {
|
|
45
40
|
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
46
41
|
},
|
|
47
42
|
};
|
|
48
43
|
}
|
|
49
|
-
// deno-lint-ignore no-explicit-any
|
|
50
44
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
51
45
|
return { data, stores };
|
|
52
46
|
}
|
|
53
47
|
|
|
54
|
-
|
|
48
|
+
test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
|
|
55
49
|
const a = mintBlackboardToken();
|
|
56
50
|
const b = mintBlackboardToken();
|
|
57
51
|
assert(a !== b, "two mints must differ");
|
|
@@ -59,12 +53,12 @@ Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
|
|
|
59
53
|
assert(a.length >= 32, "token should carry enough entropy");
|
|
60
54
|
});
|
|
61
55
|
|
|
62
|
-
|
|
56
|
+
test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
|
|
63
57
|
assertEquals(publicBaseUrl("https://pr.example.com/"), "https://pr.example.com");
|
|
64
58
|
assertEquals(publicBaseUrl("https://pr.example.com///"), "https://pr.example.com");
|
|
65
59
|
});
|
|
66
60
|
|
|
67
|
-
|
|
61
|
+
test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
|
|
68
62
|
const prev = process.env.NANO_PR_BASE_URL;
|
|
69
63
|
delete process.env.NANO_PR_BASE_URL;
|
|
70
64
|
try {
|
|
@@ -77,14 +71,14 @@ Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yiel
|
|
|
77
71
|
}
|
|
78
72
|
});
|
|
79
73
|
|
|
80
|
-
|
|
74
|
+
test("blackboardUrl: capability token rides the query string", () => {
|
|
81
75
|
assertEquals(
|
|
82
76
|
blackboardUrl("tok+en/x", "https://h"),
|
|
83
77
|
"https://h/hooks/blackboard?token=tok%2Ben%2Fx",
|
|
84
78
|
);
|
|
85
79
|
});
|
|
86
80
|
|
|
87
|
-
|
|
81
|
+
test("normalizeKind: valid passes through, anything else becomes note", () => {
|
|
88
82
|
assertEquals(normalizeKind("file-claim"), "file-claim");
|
|
89
83
|
assertEquals(normalizeKind("constraint-change"), "constraint-change");
|
|
90
84
|
assertEquals(normalizeKind("learning"), "learning");
|
|
@@ -92,7 +86,7 @@ Deno.test("normalizeKind: valid passes through, anything else becomes note", ()
|
|
|
92
86
|
assertEquals(normalizeKind(undefined), "note");
|
|
93
87
|
});
|
|
94
88
|
|
|
95
|
-
|
|
89
|
+
test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
|
|
96
90
|
const url = "https://h/hooks/blackboard?token=abc";
|
|
97
91
|
const brief = renderCoordinationBrief(url);
|
|
98
92
|
assert(brief.startsWith("\n\n---"), "must own a leading separator (appendPrompt adds none)");
|
|
@@ -112,7 +106,7 @@ Deno.test("renderCoordinationBrief: leads with a separator and teaches the proto
|
|
|
112
106
|
assertStringIncludes(brief, "Share what you learn");
|
|
113
107
|
});
|
|
114
108
|
|
|
115
|
-
|
|
109
|
+
test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
|
|
116
110
|
const { data } = memData();
|
|
117
111
|
await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
|
|
118
112
|
assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
|
|
@@ -120,7 +114,7 @@ Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise",
|
|
|
120
114
|
assertEquals(await planKeyForToken(data, ""), undefined);
|
|
121
115
|
});
|
|
122
116
|
|
|
123
|
-
|
|
117
|
+
test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
|
|
124
118
|
const { data } = memData();
|
|
125
119
|
await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
|
|
126
120
|
await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
|
|
@@ -133,14 +127,14 @@ Deno.test("appendEntry + readBlackboard: append, encode files, read back in writ
|
|
|
133
127
|
assertEquals(entries[1].author_task, "gap-8");
|
|
134
128
|
});
|
|
135
129
|
|
|
136
|
-
|
|
130
|
+
test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
|
|
137
131
|
const { data } = memData();
|
|
138
132
|
await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
|
|
139
133
|
const [e] = await readBlackboard(data, "p");
|
|
140
134
|
assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
|
|
141
135
|
});
|
|
142
136
|
|
|
143
|
-
|
|
137
|
+
test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
|
|
144
138
|
const { data } = memData();
|
|
145
139
|
await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
|
|
146
140
|
const [e] = await readBlackboard(data, "p");
|
|
@@ -148,7 +142,7 @@ Deno.test("appendEntry: a missing author defaults to 'system' and kind is normal
|
|
|
148
142
|
assertEquals(e.kind, "note");
|
|
149
143
|
});
|
|
150
144
|
|
|
151
|
-
|
|
145
|
+
test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
|
|
152
146
|
const { data, stores } = memData();
|
|
153
147
|
const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
154
148
|
const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
@@ -158,15 +152,13 @@ Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op
|
|
|
158
152
|
assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
|
|
159
153
|
});
|
|
160
154
|
|
|
161
|
-
|
|
155
|
+
test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
|
|
162
156
|
// Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
|
|
163
157
|
// pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
|
|
164
158
|
// catch branch must re-read the winner's row and return it rather than propagate the throw.
|
|
165
159
|
const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
|
|
166
160
|
let preCheckDone = false;
|
|
167
|
-
// deno-lint-ignore no-explicit-any
|
|
168
161
|
const table: any = {
|
|
169
|
-
// deno-lint-ignore require-await
|
|
170
162
|
async findOne() {
|
|
171
163
|
// Pre-check misses (row not yet visible); the recovery read after the collision hits.
|
|
172
164
|
if (!preCheckDone) {
|
|
@@ -175,21 +167,19 @@ Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500
|
|
|
175
167
|
}
|
|
176
168
|
return winner;
|
|
177
169
|
},
|
|
178
|
-
// deno-lint-ignore require-await
|
|
179
170
|
async insert() {
|
|
180
171
|
throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
|
|
181
172
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
182
173
|
});
|
|
183
174
|
},
|
|
184
175
|
};
|
|
185
|
-
// deno-lint-ignore no-explicit-any
|
|
186
176
|
const data = { table: () => table } as any as DataLayer;
|
|
187
177
|
const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
188
178
|
assertEquals(res.inserted, false, "a lost race is not a fresh insert");
|
|
189
179
|
assertEquals(res.id, 42, "returns the winning row's id");
|
|
190
180
|
});
|
|
191
181
|
|
|
192
|
-
|
|
182
|
+
test("appendEntry: a blank body is rejected", async () => {
|
|
193
183
|
const { data } = memData();
|
|
194
184
|
let threw = false;
|
|
195
185
|
try {
|
|
@@ -200,7 +190,7 @@ Deno.test("appendEntry: a blank body is rejected", async () => {
|
|
|
200
190
|
assert(threw, "blank body must throw");
|
|
201
191
|
});
|
|
202
192
|
|
|
203
|
-
|
|
193
|
+
test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
|
|
204
194
|
const { data } = memData();
|
|
205
195
|
await appendEntry(data, "p", { body: "one" });
|
|
206
196
|
await appendEntry(data, "p", { body: "two" });
|
|
@@ -210,7 +200,7 @@ Deno.test("readBlackboard: since returns only newer entries (incremental poll)",
|
|
|
210
200
|
assertEquals(tail.map((e) => e.body), ["two", "three"]);
|
|
211
201
|
});
|
|
212
202
|
|
|
213
|
-
|
|
203
|
+
test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
|
|
214
204
|
const { data } = memData();
|
|
215
205
|
await appendEntry(data, "p", { body: "one" });
|
|
216
206
|
await appendEntry(data, "p", { body: "two" });
|
|
@@ -231,14 +221,14 @@ Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to
|
|
|
231
221
|
assertEquals(next.cursor, next.entries[0].id);
|
|
232
222
|
});
|
|
233
223
|
|
|
234
|
-
|
|
224
|
+
test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
|
|
235
225
|
const { data } = memData();
|
|
236
226
|
const page = await readBlackboardPage(data, "empty");
|
|
237
227
|
assertEquals(page.entries, []);
|
|
238
228
|
assertEquals(page.cursor, 0);
|
|
239
229
|
});
|
|
240
230
|
|
|
241
|
-
|
|
231
|
+
test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
|
|
242
232
|
const { data } = memData();
|
|
243
233
|
await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
|
|
244
234
|
|
|
@@ -251,7 +241,7 @@ Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is
|
|
|
251
241
|
assertEquals(conflicts[0].author_task, "gap-2", "reports the first (winning) claimer");
|
|
252
242
|
});
|
|
253
243
|
|
|
254
|
-
|
|
244
|
+
test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
|
|
255
245
|
const { data } = memData();
|
|
256
246
|
await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
|
|
257
247
|
await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
|
|
@@ -265,7 +255,7 @@ Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim ent
|
|
|
265
255
|
assertEquals(await detectFileClaimConflicts(data, "p", { author_task: "gap-9", files: [] }), []);
|
|
266
256
|
});
|
|
267
257
|
|
|
268
|
-
|
|
258
|
+
test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
|
|
269
259
|
const { data } = memData();
|
|
270
260
|
const prior = await appendEntry(data, "p", {
|
|
271
261
|
author_task: "gap-2",
|
|
@@ -299,7 +289,7 @@ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims
|
|
|
299
289
|
assert(Number(later.id) > Number(mine.id));
|
|
300
290
|
});
|
|
301
291
|
|
|
302
|
-
|
|
292
|
+
test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
|
|
303
293
|
// Extended SQLite codes.
|
|
304
294
|
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_UNIQUE" })));
|
|
305
295
|
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_PRIMARYKEY" })));
|
package/app/blackboard.ts
CHANGED
|
@@ -58,11 +58,9 @@ export interface BlackboardInput {
|
|
|
58
58
|
dedupe_key?: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const KIND_SET = new Set<string>(BLACKBOARD_KINDS);
|
|
62
|
-
|
|
63
61
|
/** Coerce an arbitrary `kind` to a known value, defaulting to "note" for anything unrecognised. */
|
|
64
62
|
export function normalizeKind(kind: unknown): BlackboardKind {
|
|
65
|
-
return
|
|
63
|
+
return BLACKBOARD_KINDS.find((k) => k === kind) ?? "note";
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
/** A URL-safe, unguessable capability token (192 bits of randomness, base64url, no padding). */
|
|
@@ -79,9 +77,10 @@ export function mintBlackboardToken(): string {
|
|
|
79
77
|
export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBLIC_BASE_URL): string {
|
|
80
78
|
// Cascade through the fallback chain, skipping any value that is unset OR blank/whitespace, so an
|
|
81
79
|
// explicitly-set-but-empty NANO_PR_PUBLIC_BASE_URL can't yield a malformed capability URL.
|
|
82
|
-
const base =
|
|
83
|
-
.
|
|
84
|
-
|
|
80
|
+
const base =
|
|
81
|
+
[env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
|
|
82
|
+
.map((v) => v?.trim())
|
|
83
|
+
.find((v): v is string => Boolean(v)) ?? "http://localhost:3000";
|
|
85
84
|
return base.replace(/\/+$/, "");
|
|
86
85
|
}
|
|
87
86
|
|
|
@@ -313,8 +312,10 @@ export async function appendEntry(
|
|
|
313
312
|
* corruption, not a benign duplicate) is always rethrown rather than silently swallowed. */
|
|
314
313
|
export function isUniqueViolation(err: unknown): boolean {
|
|
315
314
|
if (!err || typeof err !== "object") return false;
|
|
315
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
316
316
|
const code = (err as { code?: unknown }).code;
|
|
317
317
|
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
|
|
318
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
318
319
|
const message = (err as { message?: unknown }).message;
|
|
319
320
|
return typeof message === "string" &&
|
|
320
321
|
/(unique|primary key) constraint failed|duplicate/i.test(message);
|
package/app/ensure-pr.test.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
|
|
3
3
|
// engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
|
|
4
4
|
// (observed on convergence-loop instance 94).
|
|
5
|
-
import {
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
7
|
import type { DataLayer } from "@nanobpm/urban";
|
|
7
8
|
import { canonicalPrUrl, ensurePr } from "./service.ts";
|
|
8
9
|
|
|
@@ -18,11 +19,9 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
|
|
|
18
19
|
let insertCalls = 0;
|
|
19
20
|
function tbl(name: string, key: string) {
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore require-await
|
|
22
22
|
async get(id: string) {
|
|
23
23
|
return rows.get(id);
|
|
24
24
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
26
25
|
async insert(row: any) {
|
|
27
26
|
insertCalls++;
|
|
28
27
|
if (opts.throwOnInsert) {
|
|
@@ -34,14 +33,13 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
|
|
|
34
33
|
},
|
|
35
34
|
};
|
|
36
35
|
}
|
|
37
|
-
// deno-lint-ignore no-explicit-any
|
|
38
36
|
const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
|
|
39
37
|
return { data, rows, get insertCalls() {
|
|
40
38
|
return insertCalls;
|
|
41
39
|
} };
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
test("ensurePr is a no-op when the parent already exists", async () => {
|
|
45
43
|
const mem = memData();
|
|
46
44
|
const { data, rows } = mem;
|
|
47
45
|
rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
|
|
@@ -52,7 +50,7 @@ Deno.test("ensurePr is a no-op when the parent already exists", async () => {
|
|
|
52
50
|
assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
|
|
53
51
|
});
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
|
|
56
54
|
const { data, rows } = memData();
|
|
57
55
|
await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
|
|
58
56
|
const row = rows.get("o/r#2")!;
|
|
@@ -65,7 +63,7 @@ Deno.test("ensurePr reconstructs a minimal converging row when the parent is abs
|
|
|
65
63
|
assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
|
-
|
|
66
|
+
test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
|
|
69
67
|
const { data, rows } = memData();
|
|
70
68
|
await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
|
|
71
69
|
assertEquals(
|
|
@@ -75,7 +73,7 @@ Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none i
|
|
|
75
73
|
);
|
|
76
74
|
});
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
|
|
79
77
|
const { data, rows } = memData();
|
|
80
78
|
await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
|
|
81
79
|
assertEquals(
|
|
@@ -85,27 +83,27 @@ Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one
|
|
|
85
83
|
);
|
|
86
84
|
});
|
|
87
85
|
|
|
88
|
-
|
|
86
|
+
test("ensurePr mints a token when none is supplied", async () => {
|
|
89
87
|
const { data, rows } = memData();
|
|
90
88
|
await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
|
|
91
89
|
const tok = rows.get("o/r#8")!.abandon_token;
|
|
92
90
|
assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
|
|
93
91
|
});
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
test("ensurePr prefers an explicit url over the canonical one", async () => {
|
|
96
94
|
const { data, rows } = memData();
|
|
97
95
|
const url = "https://github.com/o/r/pull/9";
|
|
98
96
|
await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
|
|
99
97
|
assertEquals(rows.get("o/r#9")!.url, url);
|
|
100
98
|
});
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
test("ensurePr swallows an insert race when the row appears anyway", async () => {
|
|
103
101
|
// insert throws (unique-violation / concurrent writer) but the row is now present → healed.
|
|
104
102
|
const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
|
|
105
103
|
await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
|
|
106
104
|
});
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
|
|
109
107
|
const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
|
|
110
108
|
let threw = false;
|
|
111
109
|
try {
|
package/app/github.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Unit tests for `fetchPrFiles` token-transport paging (issue #58): the D2 conflict-scan must get
|
|
2
2
|
// a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
5
6
|
import { fetchPrFiles } from "./github.ts";
|
|
6
7
|
|
|
7
8
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -25,31 +26,31 @@ function stubFetch(pages: number[]) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
|
|
28
|
-
const prevMode =
|
|
29
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
29
30
|
const prevFetch = globalThis.fetch;
|
|
30
|
-
|
|
31
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
31
32
|
globalThis.fetch = stubFetch(pages) as typeof fetch;
|
|
32
33
|
try {
|
|
33
34
|
return await fn();
|
|
34
35
|
} finally {
|
|
35
36
|
globalThis.fetch = prevFetch;
|
|
36
|
-
if (prevMode === undefined)
|
|
37
|
-
else
|
|
37
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
38
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
|
|
42
43
|
const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
|
|
43
44
|
assertEquals(files?.length, 142);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
|
|
47
48
|
// 5 full pages, but no `rel="next"` on the last → the list is exactly complete at the cap.
|
|
48
49
|
const files = await withTokenTransport([100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 2, "tok"));
|
|
49
50
|
assertEquals(files?.length, 500);
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
|
|
53
54
|
// 6 pages available but only 5 fetched → the 5th page still advertises `rel="next"`.
|
|
54
55
|
await assertRejects(
|
|
55
56
|
() => withTokenTransport([100, 100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 3, "tok")),
|
package/app/github.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// • auto — prefer `gh` when the binary is present; otherwise fall back to `token`.
|
|
10
10
|
//
|
|
11
11
|
// The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
|
|
12
|
-
// Cross-runtime: runs under Node (`node:child_process`)
|
|
12
|
+
// Cross-runtime: runs under Node (`node:child_process`).
|
|
13
13
|
|
|
14
14
|
/** A GitHub pull-request review, narrowed to the fields the poller needs. */
|
|
15
15
|
export interface GhReview {
|
|
@@ -26,29 +26,10 @@ export function githubTransport(): GithubTransport {
|
|
|
26
26
|
return t === "gh" || t === "token" ? t : "auto";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
interface DenoCommandCtor {
|
|
30
|
-
new (
|
|
31
|
-
command: string,
|
|
32
|
-
options: { args: string[]; stdout: "piped"; stderr: "piped" },
|
|
33
|
-
): { output(): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
29
|
/** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a
|
|
37
30
|
* `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
|
|
38
31
|
* non-zero exit with stderr as the message. */
|
|
39
32
|
async function runGh(args: string[]): Promise<string> {
|
|
40
|
-
const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
|
|
41
|
-
if (g.Deno?.Command) {
|
|
42
|
-
const { code, stdout, stderr } = await new g.Deno.Command("gh", {
|
|
43
|
-
args,
|
|
44
|
-
stdout: "piped",
|
|
45
|
-
stderr: "piped",
|
|
46
|
-
}).output();
|
|
47
|
-
if (code !== 0) {
|
|
48
|
-
throw new Error(new TextDecoder().decode(stderr).trim() || `gh exited ${code}`);
|
|
49
|
-
}
|
|
50
|
-
return new TextDecoder().decode(stdout);
|
|
51
|
-
}
|
|
52
33
|
const { execFile } = await import("node:child_process");
|
|
53
34
|
return await new Promise<string>((resolve, reject) => {
|
|
54
35
|
execFile(
|
|
@@ -66,7 +47,10 @@ async function runGh(args: string[]): Promise<string> {
|
|
|
66
47
|
let ghAvailable: Promise<boolean> | undefined;
|
|
67
48
|
/** Whether the host `gh` CLI is present (memoized — probed at most once per process). */
|
|
68
49
|
function isGhAvailable(): Promise<boolean> {
|
|
69
|
-
|
|
50
|
+
if (!ghAvailable) {
|
|
51
|
+
ghAvailable = runGh(["--version"]).then(() => true, () => false);
|
|
52
|
+
}
|
|
53
|
+
return ghAvailable;
|
|
70
54
|
}
|
|
71
55
|
|
|
72
56
|
/** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
|
|
@@ -81,6 +65,7 @@ export async function fetchPrReviews(
|
|
|
81
65
|
const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
|
|
82
66
|
if (useGh) {
|
|
83
67
|
const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
|
|
68
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
84
69
|
return JSON.parse(out) as GhReview[];
|
|
85
70
|
}
|
|
86
71
|
if (!token) return null; // token mode with no token → poller idles
|
|
@@ -88,6 +73,7 @@ export async function fetchPrReviews(
|
|
|
88
73
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
89
74
|
});
|
|
90
75
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
76
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
91
77
|
return (await r.json()) as GhReview[];
|
|
92
78
|
}
|
|
93
79
|
|
|
@@ -122,6 +108,7 @@ export async function hasPendingCopilotReviewer(
|
|
|
122
108
|
let users: { login?: string }[];
|
|
123
109
|
if (await useGh()) {
|
|
124
110
|
const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
|
|
111
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
125
112
|
users = (JSON.parse(out) as { users?: { login?: string }[] }).users ?? [];
|
|
126
113
|
} else {
|
|
127
114
|
if (!token) return null;
|
|
@@ -129,6 +116,7 @@ export async function hasPendingCopilotReviewer(
|
|
|
129
116
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
130
117
|
});
|
|
131
118
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
119
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
132
120
|
users = ((await r.json()) as { users?: { login?: string }[] }).users ?? [];
|
|
133
121
|
}
|
|
134
122
|
return users.some((u) => isCopilot(u.login));
|
|
@@ -198,6 +186,7 @@ export async function fetchPrMeta(
|
|
|
198
186
|
): Promise<PrMeta | null> {
|
|
199
187
|
if (await useGh()) {
|
|
200
188
|
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
|
|
189
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
201
190
|
const j = JSON.parse(out) as { title?: string; body?: string };
|
|
202
191
|
return { title: j.title ?? null, body: j.body ?? "" };
|
|
203
192
|
}
|
|
@@ -206,6 +195,7 @@ export async function fetchPrMeta(
|
|
|
206
195
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
207
196
|
});
|
|
208
197
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
198
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
209
199
|
const j = (await r.json()) as { title?: string; body?: string };
|
|
210
200
|
return { title: j.title ?? null, body: j.body ?? "" };
|
|
211
201
|
}
|
|
@@ -273,6 +263,7 @@ export async function fetchPrState(
|
|
|
273
263
|
"--json",
|
|
274
264
|
"state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid",
|
|
275
265
|
]);
|
|
266
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
276
267
|
const j = JSON.parse(out) as {
|
|
277
268
|
state?: string;
|
|
278
269
|
mergedAt?: string | null;
|
|
@@ -298,6 +289,7 @@ export async function fetchPrState(
|
|
|
298
289
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
299
290
|
});
|
|
300
291
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
292
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
301
293
|
const j = (await r.json()) as {
|
|
302
294
|
merged?: boolean;
|
|
303
295
|
merged_at?: string | null;
|
|
@@ -328,6 +320,7 @@ export async function fetchPrFiles(
|
|
|
328
320
|
): Promise<string[] | null> {
|
|
329
321
|
if (await useGh()) {
|
|
330
322
|
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "files"]);
|
|
323
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
331
324
|
const j = JSON.parse(out) as { files?: { path?: string }[] };
|
|
332
325
|
return (j.files ?? []).map((f) => f.path ?? "").filter((p) => p !== "");
|
|
333
326
|
}
|
|
@@ -341,6 +334,7 @@ export async function fetchPrFiles(
|
|
|
341
334
|
{ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
|
|
342
335
|
);
|
|
343
336
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
337
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
344
338
|
const batch = (await r.json()) as { filename?: string }[];
|
|
345
339
|
for (const f of batch) if (f.filename) paths.push(f.filename);
|
|
346
340
|
// A short final page means we've read every file — the list is complete.
|
|
@@ -367,6 +361,7 @@ export async function fetchPrHead(
|
|
|
367
361
|
): Promise<{ headRef: string | null; headSha: string | null } | null> {
|
|
368
362
|
if (await useGh()) {
|
|
369
363
|
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
|
|
364
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
370
365
|
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
|
|
371
366
|
return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
|
|
372
367
|
}
|
|
@@ -375,6 +370,7 @@ export async function fetchPrHead(
|
|
|
375
370
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
376
371
|
});
|
|
377
372
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
373
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
378
374
|
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
|
|
379
375
|
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
|
|
380
376
|
}
|
|
@@ -389,6 +385,7 @@ export async function fetchPrBase(
|
|
|
389
385
|
): Promise<string | null> {
|
|
390
386
|
if (await useGh()) {
|
|
391
387
|
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "baseRefName"]);
|
|
388
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
392
389
|
const j = JSON.parse(out) as { baseRefName?: string };
|
|
393
390
|
return j.baseRefName ?? null;
|
|
394
391
|
}
|
|
@@ -397,6 +394,7 @@ export async function fetchPrBase(
|
|
|
397
394
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
398
395
|
});
|
|
399
396
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
397
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
400
398
|
const j = (await r.json()) as { base?: { ref?: string } };
|
|
401
399
|
return j.base?.ref ?? null;
|
|
402
400
|
}
|
|
@@ -413,6 +411,7 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
|
|
|
413
411
|
let name: string | null = null;
|
|
414
412
|
if (await useGh()) {
|
|
415
413
|
const out = await runGh(["repo", "view", repo, "--json", "defaultBranchRef"]);
|
|
414
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
416
415
|
const j = JSON.parse(out) as { defaultBranchRef?: { name?: string } };
|
|
417
416
|
name = j.defaultBranchRef?.name ?? null;
|
|
418
417
|
} else if (token) {
|
|
@@ -420,6 +419,7 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
|
|
|
420
419
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
421
420
|
});
|
|
422
421
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
422
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
423
423
|
const j = (await r.json()) as { default_branch?: string };
|
|
424
424
|
name = j.default_branch ?? null;
|
|
425
425
|
} else {
|
|
@@ -455,6 +455,7 @@ export async function baseBranchLanded(
|
|
|
455
455
|
"--limit",
|
|
456
456
|
"20",
|
|
457
457
|
]);
|
|
458
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
458
459
|
const arr = JSON.parse(out) as { state?: string }[];
|
|
459
460
|
if (arr.some((p) => (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
|
|
460
461
|
if (arr.some((p) => (p.state ?? "").toUpperCase() === "OPEN")) return "open";
|
|
@@ -467,6 +468,7 @@ export async function baseBranchLanded(
|
|
|
467
468
|
{ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
|
|
468
469
|
);
|
|
469
470
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
471
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
470
472
|
const arr = (await r.json()) as { state?: string; merged_at?: string | null }[];
|
|
471
473
|
if (arr.some((p) => p.merged_at || (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
|
|
472
474
|
if (arr.some((p) => (p.state ?? "").toLowerCase() === "open")) return "open";
|
|
@@ -491,7 +493,6 @@ export function classifyMergeability(s: PrState): Mergeability {
|
|
|
491
493
|
// A required check failed -> a human must act. Pending checks / awaiting review -> wait.
|
|
492
494
|
// When we can't enumerate checks (failingChecks < 0, token mode) stay conservative: wait.
|
|
493
495
|
return s.failingChecks > 0 ? "blocked" : "waiting";
|
|
494
|
-
case "DRAFT":
|
|
495
496
|
default: // UNKNOWN / "" — GitHub is still computing mergeability
|
|
496
497
|
return "waiting";
|
|
497
498
|
}
|
|
@@ -550,6 +551,7 @@ export async function mergePr(
|
|
|
550
551
|
// in this pass. Trust `merged` when true; otherwise verify the PR's actual state and report
|
|
551
552
|
// `queued` when it hasn't landed yet, so the merge-loop waits for `merge-landed` rather than
|
|
552
553
|
// marking it merged prematurely.
|
|
554
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
553
555
|
const body = (await r.json().catch(() => ({}))) as { merged?: boolean };
|
|
554
556
|
if (body.merged) return { outcome: "merged", detail: "merged" };
|
|
555
557
|
const st = await fetchPrState(repo, number, token).catch(() => null);
|