@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.
- package/.bongos-core.json +57 -27
- package/.claude/skills/bug-triage/SKILL.md +191 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +77 -2
- package/docs/api-reference.md +3 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_232_task_merge_provenance.sql +64 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -0
- package/modules/government/migrations/government_011_task_merge_metic.sql +38 -0
- package/modules/lifecycle/db-ship.js +147 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/tasks.js +42 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bug-triage.js +292 -0
- package/scripts/gds/triage.js +40 -21
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +17 -0
- package/tests/bug_triage.mjs +205 -0
- package/tests/task_merge.mjs +198 -0
|
@@ -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 };
|
package/scripts/gds/triage.js
CHANGED
|
@@ -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
|
|
317
|
-
//
|
|
318
|
-
// semantic: two
|
|
319
|
-
//
|
|
320
|
-
// decision) AND
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// coincidental word.
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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 (
|
|
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(
|
|
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) =>
|
|
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.
|
|
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');
|
package/tests/abandon_task.mjs
CHANGED
|
@@ -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();
|