@bongos/core 1.19.550 → 1.19.551

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.
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ // /bug-triage — walk every open kind=bug task and verdict it one-at-a-time
3
+ // (task 1001478). The idea-triage precedent for a queue with no gate: bugs now
4
+ // land directly as kind=bug tasks with no triage step (task 1003071), so
5
+ // near-duplicate reports accumulate with nothing to fold them together.
6
+ //
7
+ // Usage:
8
+ // node scripts/gds/bug-triage.js
9
+ //
10
+ // BATCH CLUSTER PASS, mirroring /idea-triage's (task 1001465): before the
11
+ // one-at-a-time walk, near-duplicate open bug tasks are grouped (shared
12
+ // `goal_id`, high title/description word overlap — `clusterRecords`, shared
13
+ // with triage.js so the union-find/Jaccard core is written once) and offered
14
+ // ONE verdict per group: keep a canonical task and merge the rest into it,
15
+ // dismiss the whole group as won't-fix, walk them individually, or skip.
16
+ // Partitioned by `goal_id` rather than `kind` (every row here is already
17
+ // kind=bug, so that gate would be a no-op) — two bug reports in unrelated
18
+ // goals describing similar-sounding symptoms are not the same triage
19
+ // decision, the same reasoning idea-triage applies to its own `kind` gate.
20
+ //
21
+ // Prompts per bug task:
22
+ // [m]erge with #N → POST /tasks/:id/merge {into_task_id:N} (N is the
23
+ // canonical TASK to keep; this task's status becomes
24
+ // 'abandoned' with merged_into_task_id=N — no new
25
+ // 'merged' status, see db-ship.js mergeTask's header)
26
+ // [w]on't-fix → POST /tasks/:id/abandon {reason} (not a bug / already
27
+ // fixed / can't reproduce — the report itself is closed,
28
+ // not folded into another task)
29
+ // [s]kip → no write; the task surfaces again next triage
30
+ // [q]uit → exit early, print partial summary
31
+ //
32
+ // Prompts per duplicate cluster (before the per-task walk):
33
+ // [k]eep #N → merge every other member into #N (default: the
34
+ // oldest); #N itself is NOT verdicted here — it still
35
+ // gets the normal per-task walk below
36
+ // [d]iscard → won't-fix every member in the cluster
37
+ // [i]ndividually → ignore the cluster grouping; walk each member normally
38
+ // [s]kip → no write; the whole cluster surfaces again next triage
39
+ //
40
+ // Exit codes:
41
+ // 0 walked cleanly (incl. nothing open)
42
+ // 1 api / network error
43
+ // 2 no session
44
+
45
+ const readline = require('node:readline');
46
+ const { cliClient, requireSession, cliExit } = require('./cli-lib');
47
+ const { clusterRecords } = require('./triage');
48
+
49
+ function prompt(rl, question) {
50
+ return new Promise((resolve) => rl.question(question, resolve));
51
+ }
52
+
53
+ function trim(s, n) {
54
+ const str = String(s || '');
55
+ return str.length > n ? `${str.slice(0, n - 1)}…` : str;
56
+ }
57
+
58
+ // Statuses a bug can still be triaged FROM. 'shipped'/'abandoned' are terminal
59
+ // (the latter includes already-merged rows, since merge reuses that status —
60
+ // see db-ship.js mergeTask's header), so neither belongs in an "open" walk.
61
+ const OPEN_STATUSES = new Set(['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed']);
62
+
63
+ async function listOpenBugTasks() {
64
+ const api = await cliClient();
65
+ const r = await api.tasks.getTasks({ query: { kind: 'bug', limit: 1000 } });
66
+ if (!r.ok) throw new Error(`GET /tasks?kind=bug failed (${r.status}): ${JSON.stringify(r.data)}`);
67
+ // GET /tasks responds { tasks: [...] } (modules/lifecycle/routes/tasks.js),
68
+ // not a bare array — triage.js:163's `r.data?.ideas ?? []` is the same shape
69
+ // one route over. Grade finding: this was `(r.data || [])`, an object with
70
+ // no .filter, so every real run threw before listing a single bug.
71
+ return (r.data?.tasks ?? []).filter((t) => OPEN_STATUSES.has(t.status));
72
+ }
73
+
74
+ async function mergeTaskInto(id, intoTaskId, reason) {
75
+ const api = await cliClient();
76
+ const r = await api.tasks.postTasksIdMerge({ id, body: { into_task_id: Number(intoTaskId), reason: reason || '' } });
77
+ if (!r.ok) throw Object.assign(new Error(`merge failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
78
+ return r.data;
79
+ }
80
+
81
+ async function dismissTask(id, reason) {
82
+ const api = await cliClient();
83
+ const r = await api.tasks.postTasksIdAbandon({ id, body: { reason: reason || '' } });
84
+ if (!r.ok) throw Object.assign(new Error(`won't-fix failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
85
+ return r.data;
86
+ }
87
+
88
+ function printTask(task, idx, total) {
89
+ console.log(`\n[${idx + 1}/${total}] #${task.id} [${task.status}] ${task.title}`);
90
+ if (task.description) console.log(` ${trim(task.description, 300)}`);
91
+ if (task.goal_id) console.log(` goal ${task.goal_id}`);
92
+ }
93
+
94
+ function printCluster(cluster) {
95
+ const sep = '┄'.repeat(72);
96
+ console.log(`\n${sep}`);
97
+ console.log(`Cluster of ${cluster.length} likely-duplicate bug reports (oldest first — the suggested keeper):`);
98
+ for (const m of cluster) console.log(` #${m.id} [${m.status}] ${trim(m.title, 80)}`);
99
+ console.log(sep);
100
+ }
101
+
102
+ // The interactive batch pass over one cluster. Mutates `state` (summary
103
+ // counts) and returns nothing; `clusteredIds` is mutated in place so a choice
104
+ // of "individually" or "keep #N" puts the affected task(s) back into the
105
+ // normal per-task walk that runs after every cluster has been offered.
106
+ // Mirrors triage.js's handleCluster exactly, task-shaped.
107
+ async function handleCluster(rl, cluster, clusteredIds, state) {
108
+ printCluster(cluster);
109
+ let handled = false;
110
+ while (!handled) {
111
+ const ans = (await prompt(rl, ` [k]eep #${cluster[0].id} & merge the rest / [d]iscard all / [i]ndividually / [s]kip cluster > `)).trim().toLowerCase();
112
+
113
+ if (ans === 's' || ans === 'skip' || ans === '') { handled = true; break; }
114
+
115
+ if (ans === 'i' || ans === 'individually') {
116
+ for (const m of cluster) clusteredIds.delete(m.id);
117
+ console.log(' → walking this cluster\'s bugs individually');
118
+ handled = true; break;
119
+ }
120
+
121
+ if (ans === 'd' || ans === 'discard') {
122
+ const reason = (await prompt(rl, ' reason (optional, enter to skip): ')).trim();
123
+ for (const m of cluster) {
124
+ try {
125
+ await dismissTask(m.id, reason || 'won\'t-fix as part of a batch-verdicted duplicate cluster');
126
+ state.summary.dismissed++;
127
+ state.dismissedIds.push(m.id);
128
+ } catch (err) {
129
+ console.error(` ! #${m.id}: ${err.message}`);
130
+ }
131
+ }
132
+ console.log(` ✖ dismissed ${cluster.length} duplicate bug reports`);
133
+ handled = true; break;
134
+ }
135
+
136
+ if (ans === 'k' || ans === 'keep' || ans.startsWith('k ') || ans.startsWith('k#')) {
137
+ let canonicalId = cluster[0].id;
138
+ const inline = ans.replace(/^k(eep)?(\s|#)*/, '').trim();
139
+ if (inline) {
140
+ const n = Number(inline.replace(/^#/, ''));
141
+ if (Number.isFinite(n) && n > 0 && cluster.some((m) => String(m.id) === String(n))) canonicalId = n;
142
+ else if (Number.isFinite(n) && n > 0) console.log(` ! #${n} isn't in this cluster; keeping #${canonicalId}`);
143
+ }
144
+ for (const m of cluster) {
145
+ if (String(m.id) === String(canonicalId)) continue;
146
+ try {
147
+ await mergeTaskInto(m.id, canonicalId, `duplicate cluster batch-merged into #${canonicalId}`);
148
+ state.summary.merged++;
149
+ state.mergedIds.push(m.id);
150
+ } catch (err) {
151
+ console.error(` ! #${m.id}: ${err.message}`);
152
+ // A failed merge must not silently vanish the bug for this session:
153
+ // without this, the member stays in clusteredIds and the later
154
+ // `tasks.filter((t) => !clusteredIds.has(t.id))` drops it from BOTH
155
+ // the batch verdict (it never merged) and the individual walk —
156
+ // one stderr line and the report is skipped entirely.
157
+ clusteredIds.delete(m.id);
158
+ }
159
+ }
160
+ // The keeper is untouched by the batch verdict — it still needs its own
161
+ // won't-fix/merge/skip decision, so it goes back into the normal walk.
162
+ clusteredIds.delete(canonicalId);
163
+ console.log(` ⇢ merged ${cluster.length - 1} duplicate(s) into #${canonicalId} — #${canonicalId} still needs its own verdict below`);
164
+ handled = true; break;
165
+ }
166
+
167
+ console.log(' ? unknown — type k, d, i, or s');
168
+ }
169
+ }
170
+
171
+ async function main() {
172
+ await requireSession();
173
+
174
+ let tasks;
175
+ try {
176
+ tasks = await listOpenBugTasks();
177
+ } catch (err) {
178
+ console.error(err.message);
179
+ cliExit(1);
180
+ return;
181
+ }
182
+
183
+ if (tasks.length === 0) {
184
+ console.log('bug queue empty — nothing to triage.');
185
+ cliExit(0);
186
+ return;
187
+ }
188
+
189
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
+ const summary = { merged: 0, dismissed: 0, skipped: 0 };
191
+ const mergedIds = [];
192
+ const dismissedIds = [];
193
+ let quit = false;
194
+
195
+ // BATCH CLUSTER PASS — partitioned by goal_id, not kind (every row here is
196
+ // already kind=bug, so a kind gate would be a no-op; see module header).
197
+ const { clusters, clusteredIds } = clusterRecords(tasks, {
198
+ textOf: (t) => `${t.title || ''} ${t.description || ''}`,
199
+ kindOf: (t) => t.goal_id || null,
200
+ timeOf: (t) => new Date(t.created_at),
201
+ });
202
+ if (clusters.length > 0) {
203
+ console.log(`\n🔗 ${clusters.length} likely-duplicate cluster${clusters.length === 1 ? '' : 's'} found (${clusteredIds.size} bug reports) — batch verdict available before the individual walk.`);
204
+ const state = { summary, mergedIds, dismissedIds };
205
+ for (const cluster of clusters) {
206
+ await handleCluster(rl, cluster, clusteredIds, state);
207
+ }
208
+ }
209
+ tasks = tasks.filter((t) => !clusteredIds.has(t.id));
210
+ if (tasks.length === 0) {
211
+ console.log('\nnothing left to walk individually — every open bug report was resolved in a batch cluster verdict.');
212
+ rl.close();
213
+ printSummary(summary, mergedIds, dismissedIds);
214
+ cliExit(0);
215
+ return;
216
+ }
217
+
218
+ console.log(`\n🐛 ${tasks.length} open bug report${tasks.length === 1 ? '' : 's'} to triage individually.`);
219
+
220
+ let walked = 0;
221
+ for (let i = 0; i < tasks.length && !quit; i++) {
222
+ const task = tasks[i];
223
+ printTask(task, i, tasks.length);
224
+
225
+ let handled = false;
226
+ while (!handled) {
227
+ const ans = (await prompt(rl, '[m]erge / [w]on\'t-fix / [s]kip / [q]uit > ')).trim().toLowerCase();
228
+
229
+ if (ans === 'q' || ans === 'quit') { quit = true; handled = true; break; }
230
+
231
+ if (ans === 's' || ans === 'skip' || ans === '') {
232
+ summary.skipped++;
233
+ handled = true; break;
234
+ }
235
+
236
+ if (ans === 'w' || ans === 'wontfix' || ans === "won't-fix" || ans === 'dismiss') {
237
+ const reason = (await prompt(rl, ' reason (not a bug / already fixed / cannot reproduce): ')).trim();
238
+ try {
239
+ await dismissTask(task.id, reason);
240
+ summary.dismissed++;
241
+ dismissedIds.push(task.id);
242
+ console.log(` ✖ dismissed #${task.id}`);
243
+ } catch (err) {
244
+ console.error(` ! ${err.message}`);
245
+ continue;
246
+ }
247
+ handled = true; break;
248
+ }
249
+
250
+ if (ans === 'm' || ans === 'merge' || ans.startsWith('m ') || ans.startsWith('m#')) {
251
+ const inline = ans.replace(/^m(erge)?(\s|#)*/, '').trim();
252
+ const target = inline || (await prompt(rl, ' merge into which task id? #')).trim().replace(/^#/, '');
253
+ const n = Number(target);
254
+ if (!Number.isFinite(n) || n <= 0) { console.log(' ? not a valid task id'); continue; }
255
+ try {
256
+ await mergeTaskInto(task.id, n, '');
257
+ summary.merged++;
258
+ mergedIds.push(task.id);
259
+ console.log(` ⇢ merged #${task.id} into #${n}`);
260
+ } catch (err) {
261
+ console.error(` ! ${err.message}`);
262
+ continue;
263
+ }
264
+ handled = true; break;
265
+ }
266
+
267
+ console.log(' ? unknown — type m, w, s, or q');
268
+ }
269
+ if (handled && !quit) walked++;
270
+ }
271
+
272
+ rl.close();
273
+ printSummary(summary, mergedIds, dismissedIds, tasks.length - walked);
274
+ cliExit(0);
275
+ }
276
+
277
+ function printSummary(summary, mergedIds, dismissedIds, unseen = 0) {
278
+ console.log('\n── bug triage summary ──');
279
+ console.log(` merged: ${summary.merged}${mergedIds.length ? ` (${mergedIds.map((i) => `#${i}`).join(', ')})` : ''}`);
280
+ console.log(` dismissed: ${summary.dismissed}${dismissedIds.length ? ` (${dismissedIds.map((i) => `#${i}`).join(', ')})` : ''}`);
281
+ console.log(` skipped: ${summary.skipped}`);
282
+ if (unseen > 0) console.log(` ${unseen} unseen (quit early) — will surface again next triage`);
283
+ }
284
+
285
+ if (require.main === module) {
286
+ main().catch((err) => {
287
+ console.error('fatal:', err);
288
+ cliExit(1);
289
+ });
290
+ }
291
+
292
+ module.exports = { listOpenBugTasks, mergeTaskInto, dismissTask, OPEN_STATUSES };
@@ -313,31 +313,35 @@ function jaccard(a, b) {
313
313
  return shared / (a.size + b.size - shared);
314
314
  }
315
315
 
316
- // Group likely-duplicate OPEN ideas so the walk can offer one batch verdict
317
- // per group instead of re-litigating each member individually. Heuristic, not
318
- // semantic: two ideas cluster only when they share the same `kind` (a bug and
319
- // a feature asking the same underlying question are not the same triage
320
- // decision) AND their title+body word sets clear both a minimum SHARED-word
321
- // floor and a Jaccard similarity threshold — the floor stops two long,
322
- // unrelated ideas from clustering on a borderline ratio built from a single
323
- // coincidental word. Full Ideas are never eligible: they get their own shape
324
- // recommendation (SKILL.md Step 2.5), and batching one away would skip that
325
- // judgment. Pure (no I/O, no randomness, no idea mutated) — directly
326
- // unit-testable; the interactive merge/discard/skip choice lives in main().
327
- // Returns { clusters, clusteredIds }: clusters is an array of idea-arrays
328
- // (each length >= 2, oldest member first — the natural "keep this one"
329
- // default), clusteredIds is a Set of every idea id inside some cluster.
330
- function clusterIdeas(ideas, { threshold = 0.34, minShared = 2 } = {}) {
331
- const eligible = ideas.filter((i) => (i.grade || 'quick') !== 'full');
332
- const words = eligible.map((i) => significantWords(`${i.title || ''} ${i.body_md || ''}`));
333
- const n = eligible.length;
316
+ // Group likely-duplicate records so a walk can offer one batch verdict per
317
+ // group instead of re-litigating each member individually. Heuristic, not
318
+ // semantic: two records cluster only when `kindOf` agrees (a bug and a
319
+ // feature asking the same underlying question are not the same triage
320
+ // decision) AND `textOf`'s word sets clear both a minimum SHARED-word floor
321
+ // and a Jaccard similarity threshold — the floor stops two long, unrelated
322
+ // records from clustering on a borderline ratio built from a single
323
+ // coincidental word. Pure (no I/O, no randomness, nothing mutated) — directly
324
+ // unit-testable; the interactive merge/discard/skip choice lives in each
325
+ // caller's own walk. Returns { clusters, clusteredIds }: clusters is an array
326
+ // of record-arrays (each length >= 2, sorted oldest-`timeOf`-first — the
327
+ // natural "keep this one" default), clusteredIds is a Set of every id (by
328
+ // `.id`) inside some cluster.
329
+ //
330
+ // Extracted from the idea-only `clusterIdeas` below (task 1001478) so
331
+ // /bug-triage's kind=bug tasks can reuse the same union-find/Jaccard core
332
+ // against `description`/`created_at` instead of `body_md`/`captured_at` —
333
+ // duplicating the algorithm would be the one thing worth avoiding twice.
334
+ function clusterRecords(records, { textOf, kindOf, timeOf, threshold = 0.34, minShared = 2 }) {
335
+ const words = records.map((r) => significantWords(textOf(r)));
336
+ const kinds = records.map((r) => kindOf(r));
337
+ const n = records.length;
334
338
  const parent = Array.from({ length: n }, (_, i) => i);
335
339
  function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
336
340
  function union(a, b) { const ra = find(a); const rb = find(b); if (ra !== rb) parent[ra] = rb; }
337
341
 
338
342
  for (let i = 0; i < n; i++) {
339
343
  for (let j = i + 1; j < n; j++) {
340
- if ((eligible[i].kind || null) !== (eligible[j].kind || null)) continue;
344
+ if (kinds[i] !== kinds[j]) continue;
341
345
  let shared = 0;
342
346
  for (const w of words[i]) if (words[j].has(w)) shared++;
343
347
  if (shared < minShared) continue;
@@ -349,19 +353,33 @@ function clusterIdeas(ideas, { threshold = 0.34, minShared = 2 } = {}) {
349
353
  for (let i = 0; i < n; i++) {
350
354
  const root = find(i);
351
355
  if (!groups.has(root)) groups.set(root, []);
352
- groups.get(root).push(eligible[i]);
356
+ groups.get(root).push(records[i]);
353
357
  }
354
358
  const clusters = [];
355
359
  const clusteredIds = new Set();
356
360
  for (const members of groups.values()) {
357
361
  if (members.length < 2) continue;
358
- members.sort((a, b) => new Date(a.captured_at) - new Date(b.captured_at));
362
+ members.sort((a, b) => timeOf(a) - timeOf(b));
359
363
  clusters.push(members);
360
364
  for (const m of members) clusteredIds.add(m.id);
361
365
  }
362
366
  return { clusters, clusteredIds };
363
367
  }
364
368
 
369
+ // The idea-shaped call: Full Ideas are never eligible (they get their own
370
+ // shape recommendation, SKILL.md Step 2.5, and batching one away would skip
371
+ // that judgment), clustered on title+body_md / kind / captured_at. Same
372
+ // signature and behavior as before the clusterRecords extraction.
373
+ function clusterIdeas(ideas, { threshold = 0.34, minShared = 2 } = {}) {
374
+ const eligible = ideas.filter((i) => (i.grade || 'quick') !== 'full');
375
+ return clusterRecords(eligible, {
376
+ textOf: (i) => `${i.title || ''} ${i.body_md || ''}`,
377
+ kindOf: (i) => i.kind || null,
378
+ timeOf: (i) => new Date(i.captured_at),
379
+ threshold, minShared,
380
+ });
381
+ }
382
+
365
383
  function printCluster(cluster) {
366
384
  const sep = '┄'.repeat(72);
367
385
  console.log(`\n${sep}`);
@@ -671,6 +689,7 @@ module.exports = {
671
689
  loadDevelopment,
672
690
  formatDevelopment,
673
691
  clusterIdeas,
692
+ clusterRecords,
674
693
  significantWords,
675
694
  jaccard,
676
695
  };
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.550'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.551'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -151,6 +151,9 @@ await test('restore: an abandoned task flips to backlog, COMMITs, reports previo
151
151
  });
152
152
 
153
153
  await test("restore: clears only an 'Abandoned: …' value_summary, leaving any other note", async () => {
154
+ // mergeTask (task 1001478) preserves value_summary rather than synthesizing
155
+ // a "Merged into #N" default, so there is no merge-specific stamp for
156
+ // restore to clean up here beyond the abandon one.
154
157
  const pool = fakePool({ status: 'abandoned', updatedStatus: 'backlog' });
155
158
  await restoreTask({ taskId: TASK }, { pool });
156
159
  const upd = find(pool._client.queries, /^UPDATE tasks/);
@@ -161,6 +164,20 @@ await test("restore: clears only an 'Abandoned: …' value_summary, leaving any
161
164
  assert.equal(upd.params.length, 1, 'only the task id is bound');
162
165
  });
163
166
 
167
+ await test('restore: clears merged_into_task_id + triage_note (task 1001478 grade finding)', async () => {
168
+ // Without this, restoring a MERGED task (status flips 'abandoned'→'backlog'
169
+ // while merged_into_task_id stays set) violates core_232's CHECK
170
+ // (merged_into_task_id IS NULL OR status='abandoned') — the exact 23514
171
+ // mergeTask's own header prescribes this route to fix ("retarget by
172
+ // restoring first").
173
+ const pool = fakePool({ status: 'abandoned', updatedStatus: 'backlog' });
174
+ await restoreTask({ taskId: TASK }, { pool });
175
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
176
+ const sql = String(upd.sql).replace(/\s+/g, ' ');
177
+ assert.match(sql, /merged_into_task_id = NULL/, 'must clear the merge target so the CHECK stays satisfied');
178
+ assert.match(sql, /triage_note = NULL/, 'must clear the triage note left by the merge it undoes');
179
+ });
180
+
164
181
  await test('restore: a task that is not abandoned is refused and rolls back', async () => {
165
182
  for (const status of ['ready', 'backlog', 'shipped']) {
166
183
  const pool = fakePool({ status });
@@ -0,0 +1,205 @@
1
+ // tests/bug_triage.mjs
2
+ //
3
+ // Regression guard for task 1001478 — /bug-triage's task-shaped reuse of
4
+ // triage.js's generalized clusterRecords (extracted from the idea-only
5
+ // clusterIdeas so the union-find/Jaccard core is written once), the
6
+ // OPEN_STATUSES set that decides which kind=bug tasks a triage walk sees, and
7
+ // the three HTTP-facing functions (listOpenBugTasks/mergeTaskInto/
8
+ // dismissTask) a grading round caught completely untested — which is exactly
9
+ // how a real envelope-shape bug (GET /tasks responds `{ tasks: [...] }`, not
10
+ // a bare array; `(r.data || []).filter` threw on every run) shipped green.
11
+ //
12
+ // cliClient() is mocked by mutating cli-lib.js's exports BEFORE requiring
13
+ // bug-triage.js (same trick as tests/idea_triage.mjs patching
14
+ // `api.pool.query` before requiring inbox.js): bug-triage.js destructures
15
+ // `{ cliClient }` at require time, so the patched function is what it binds.
16
+ //
17
+ // Run: node tests/bug_triage.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import { makeRunner } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+
25
+ // A fake api client whose `tasks.*` methods answer from a per-test script of
26
+ // { ok, data, status } responses, and record every call for assertion.
27
+ const calls = [];
28
+ let responses = {};
29
+ const cliLib = require('../scripts/gds/cli-lib.js');
30
+ cliLib.cliClient = async () => ({
31
+ tasks: {
32
+ getTasks: async (args) => { calls.push({ method: 'getTasks', args }); return responses.getTasks; },
33
+ postTasksIdMerge: async (args) => { calls.push({ method: 'postTasksIdMerge', args }); return responses.merge; },
34
+ postTasksIdAbandon: async (args) => { calls.push({ method: 'postTasksIdAbandon', args }); return responses.abandon; },
35
+ },
36
+ });
37
+
38
+ const { clusterRecords } = require('../scripts/gds/triage.js');
39
+ const { listOpenBugTasks, mergeTaskInto, dismissTask, OPEN_STATUSES } = require('../scripts/gds/bug-triage.js');
40
+
41
+ const { test, summary } = makeRunner();
42
+
43
+ const mkBug = (id, title, { goalId = 1000001, description = '', createdAt = '2026-01-01T00:00:00Z' } = {}) =>
44
+ ({ id, title, description, goal_id: goalId, created_at: createdAt });
45
+
46
+ const bugCluster = (records, opts = {}) => clusterRecords(records, {
47
+ textOf: (t) => `${t.title || ''} ${t.description || ''}`,
48
+ kindOf: (t) => t.goal_id || null,
49
+ timeOf: (t) => new Date(t.created_at),
50
+ ...opts,
51
+ });
52
+
53
+ // ---------- OPEN_STATUSES ----------
54
+
55
+ await test('OPEN_STATUSES: excludes shipped and abandoned (merged tasks reuse abandoned)', () => {
56
+ assert.ok(!OPEN_STATUSES.has('shipped'));
57
+ assert.ok(!OPEN_STATUSES.has('abandoned'));
58
+ });
59
+
60
+ await test('OPEN_STATUSES: includes every live status a bug can sit in', () => {
61
+ for (const s of ['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed']) {
62
+ assert.ok(OPEN_STATUSES.has(s), `${s} should be an open bug status`);
63
+ }
64
+ });
65
+
66
+ // ---------- clusterRecords, task-shaped ----------
67
+
68
+ await test('clusterRecords: near-duplicate bug reports in the SAME goal cluster together', () => {
69
+ const bugs = [
70
+ mkBug(1, 'Login button does nothing on Safari', { createdAt: '2026-01-03T00:00:00Z' }),
71
+ mkBug(2, 'Safari: login button unresponsive', { createdAt: '2026-01-01T00:00:00Z' }),
72
+ mkBug(3, 'The login button is broken in Safari', { createdAt: '2026-01-02T00:00:00Z' }),
73
+ ];
74
+ const { clusters, clusteredIds } = bugCluster(bugs);
75
+ assert.equal(clusters.length, 1, 'expected exactly one cluster');
76
+ assert.equal(clusters[0].length, 3);
77
+ assert.deepEqual(clusteredIds, new Set([1, 2, 3]));
78
+ assert.equal(clusters[0][0].id, 2, 'oldest first — the suggested keeper');
79
+ });
80
+
81
+ await test('clusterRecords: unrelated bug reports never cluster', () => {
82
+ const bugs = [
83
+ mkBug(1, 'Login button does nothing on Safari'),
84
+ mkBug(2, 'Export to CSV drops the last row'),
85
+ mkBug(3, 'Dark mode flickers on page load'),
86
+ ];
87
+ const { clusters, clusteredIds } = bugCluster(bugs);
88
+ assert.equal(clusters.length, 0);
89
+ assert.equal(clusteredIds.size, 0);
90
+ });
91
+
92
+ await test('clusterRecords: same-symptom bugs in DIFFERENT goals do not cluster', () => {
93
+ const bugs = [
94
+ mkBug(1, 'Login button does nothing on Safari', { goalId: 1000001 }),
95
+ mkBug(2, 'Login button does nothing on Safari', { goalId: 1000002 }),
96
+ ];
97
+ const { clusters, clusteredIds } = bugCluster(bugs);
98
+ assert.equal(clusters.length, 0, 'two goals reporting the same symptom are not one triage decision');
99
+ assert.equal(clusteredIds.size, 0);
100
+ });
101
+
102
+ await test('clusterRecords: a single overlapping word alone is not enough (minShared floor)', () => {
103
+ const bugs = [
104
+ mkBug(1, 'Improve the deploy pipeline reliability with retries'),
105
+ mkBug(2, 'Improve onboarding copy for new builders'),
106
+ ];
107
+ const { clusters } = bugCluster(bugs);
108
+ assert.equal(clusters.length, 0, 'sharing only "improve" must not cluster two unrelated reports');
109
+ });
110
+
111
+ await test('clusterRecords: no goal_id (null) still partitions consistently', () => {
112
+ const bugs = [
113
+ mkBug(1, 'Login button does nothing on Safari', { goalId: null }),
114
+ mkBug(2, 'Safari login button is unresponsive', { goalId: null }),
115
+ ];
116
+ const { clusters } = bugCluster(bugs);
117
+ assert.equal(clusters.length, 1, 'two homeless bug reports (goal_id null) can still cluster against each other');
118
+ });
119
+
120
+ await test('clusterRecords: empty input → no clusters', () => {
121
+ const { clusters, clusteredIds } = bugCluster([]);
122
+ assert.equal(clusters.length, 0);
123
+ assert.equal(clusteredIds.size, 0);
124
+ });
125
+
126
+ // ---------- listOpenBugTasks (the envelope-shape blocker) ----------
127
+
128
+ await test('listOpenBugTasks: reads the { tasks: [...] } envelope, not a bare array', async () => {
129
+ responses.getTasks = { ok: true, data: { tasks: [mkBug(1, 'x', {}), { ...mkBug(2, 'y'), status: 'ready' }] } };
130
+ responses.getTasks.data.tasks[0].status = 'ready';
131
+ const out = await listOpenBugTasks();
132
+ assert.equal(out.length, 2, '(r.data || []).filter would have thrown TypeError here — r.data is an object, not an array');
133
+ });
134
+
135
+ await test('listOpenBugTasks: filters out shipped/abandoned, keeps every open status', async () => {
136
+ responses.getTasks = {
137
+ ok: true,
138
+ data: { tasks: [
139
+ { ...mkBug(1, 'a'), status: 'ready' },
140
+ { ...mkBug(2, 'b'), status: 'shipped' },
141
+ { ...mkBug(3, 'c'), status: 'abandoned' },
142
+ { ...mkBug(4, 'd'), status: 'confirmed' },
143
+ ] },
144
+ };
145
+ const out = await listOpenBugTasks();
146
+ assert.deepEqual(out.map((t) => t.id), [1, 4]);
147
+ });
148
+
149
+ await test('listOpenBugTasks: queries kind=bug with a bounded limit', async () => {
150
+ calls.length = 0;
151
+ responses.getTasks = { ok: true, data: { tasks: [] } };
152
+ await listOpenBugTasks();
153
+ assert.equal(calls[0].method, 'getTasks');
154
+ assert.deepEqual(calls[0].args.query, { kind: 'bug', limit: 1000 });
155
+ });
156
+
157
+ await test('listOpenBugTasks: a non-ok response throws with the status + body', async () => {
158
+ responses.getTasks = { ok: false, status: 500, data: { error: 'boom' } };
159
+ await assert.rejects(() => listOpenBugTasks(), /500/);
160
+ });
161
+
162
+ // ---------- mergeTaskInto ----------
163
+
164
+ await test('mergeTaskInto: posts into_task_id + reason to the merge route', async () => {
165
+ calls.length = 0;
166
+ responses.merge = { ok: true, data: { ok: true, taskId: 5, taskStatus: 'abandoned', mergedIntoTaskId: 9 } };
167
+ const out = await mergeTaskInto(5, 9, 'same report');
168
+ assert.equal(calls[0].method, 'postTasksIdMerge');
169
+ assert.deepEqual(calls[0].args, { id: 5, body: { into_task_id: 9, reason: 'same report' } });
170
+ assert.equal(out.mergedIntoTaskId, 9);
171
+ });
172
+
173
+ await test('mergeTaskInto: intoTaskId is coerced to a Number even if passed as a string', async () => {
174
+ calls.length = 0;
175
+ responses.merge = { ok: true, data: {} };
176
+ await mergeTaskInto(5, '9', '');
177
+ assert.equal(calls[0].args.body.into_task_id, 9);
178
+ assert.equal(typeof calls[0].args.body.into_task_id, 'number');
179
+ });
180
+
181
+ await test('mergeTaskInto: a refused merge throws with the server error code attached', async () => {
182
+ responses.merge = { ok: false, status: 409, data: { error: { code: 'MERGE_TARGET_MERGED' } } };
183
+ const err = await mergeTaskInto(5, 9, '').catch((e) => e);
184
+ assert.ok(err instanceof Error);
185
+ assert.equal(err.code, 'MERGE_TARGET_MERGED');
186
+ });
187
+
188
+ // ---------- dismissTask ----------
189
+
190
+ await test('dismissTask: posts a reason to the abandon route', async () => {
191
+ calls.length = 0;
192
+ responses.abandon = { ok: true, data: { ok: true, taskId: 5, taskStatus: 'abandoned' } };
193
+ await dismissTask(5, 'cannot reproduce');
194
+ assert.equal(calls[0].method, 'postTasksIdAbandon');
195
+ assert.deepEqual(calls[0].args, { id: 5, body: { reason: 'cannot reproduce' } });
196
+ });
197
+
198
+ await test('dismissTask: a refused won\'t-fix throws with the server error code attached', async () => {
199
+ responses.abandon = { ok: false, status: 409, data: { error: { code: 'TASK_HAS_ACTIVE_CLAIM' } } };
200
+ const err = await dismissTask(5, '').catch((e) => e);
201
+ assert.equal(err.code, 'TASK_HAS_ACTIVE_CLAIM');
202
+ });
203
+
204
+ // -------------------------------------------------------------------------
205
+ summary();