@bongos/core 1.19.453 → 1.19.455
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 +25 -20
- package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/discord/db.js +0 -3
- package/modules/discord/discord-bot.js +8 -1
- package/modules/discord/discord-bugs.js +133 -74
- package/modules/discord/module.json +1 -1
- package/modules/lifecycle/goal-suggest.js +70 -2
- package/modules/lifecycle/lifecycle.js +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/discord_bugs.mjs +110 -31
- package/tests/filing_abuse_matrix.mjs +394 -0
- package/tests/goal_suggest.mjs +107 -1
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.455",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.455",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.455",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.455'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/discord_bugs.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Unit tests for Discord #bugs → GDS tasks (kind='bug') (Phase 3 F8, ADR 0047).
|
|
4
4
|
// Pure screening/landing + orchestration via injected fakes — no Discord, no DB:
|
|
5
5
|
// - screenBug: message → { ok, title, bodyMd }; blank/short/text-free rejected.
|
|
6
|
-
// -
|
|
6
|
+
// - the landing: Metic+ → 'ready'; everyone else linked → 'backlog'. Decided by
|
|
7
|
+
// the ideas landing matrix since task 1003067, not by this module.
|
|
7
8
|
// - recordInboundBug: files via createTask under the anti-abuse guard —
|
|
8
9
|
// linked-only (unlinked rejected), rank-aware landing, per-builder
|
|
9
10
|
// rate-limit, body screen; audits surface='discord_bot'; never throws.
|
|
@@ -23,7 +24,9 @@ function t(name, fn) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const GOOD_ID = '123456789012345678';
|
|
26
|
-
|
|
27
|
+
// task 1003067: versionId and credits are gone from this vector's config — the
|
|
28
|
+
// goal carries the version and the reward auto-assigns at the create layer.
|
|
29
|
+
const CFG = { rateLimitPerHour: 5 };
|
|
27
30
|
|
|
28
31
|
console.log('Discord #bugs → GDS tasks (Phase 3 F8):');
|
|
29
32
|
|
|
@@ -51,21 +54,16 @@ await t('screenBug: long first line is capped (≤200, ellipsis)', () => {
|
|
|
51
54
|
assert.ok(s.title.endsWith('…'));
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
// ---- decideBugLanding ------------------------------------------------------
|
|
55
|
-
await t('decideBugLanding: Metic+ → ready', () => {
|
|
56
|
-
assert.equal(bg.decideBugLanding('metic'), 'ready');
|
|
57
|
-
assert.equal(bg.decideBugLanding('archon'), 'ready');
|
|
58
|
-
assert.equal(bg.decideBugLanding('ARCHON'), 'ready');
|
|
59
|
-
});
|
|
60
|
-
await t('decideBugLanding: Xenos/Thetes/unknown → backlog', () => {
|
|
61
|
-
for (const r of ['xenos', 'thetes', 'periokoi', '', null, undefined]) {
|
|
62
|
-
assert.equal(bg.decideBugLanding(r), 'backlog');
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
57
|
|
|
66
58
|
// ---- recordInboundBug (fakes) ---------------------------------------------
|
|
59
|
+
// The fake capture mirrors the real landing rule rather than hardcoding a status,
|
|
60
|
+
// so a matrix change shows up here as a failure instead of a stale fixture.
|
|
61
|
+
// task 1003067: the fake defers to the REAL landing matrix, so this fixture can
|
|
62
|
+
// never quietly disagree with the rule the server actually applies.
|
|
63
|
+
const { decideLanding } = require('../modules/ideas/routing.js');
|
|
64
|
+
const READY_OR_BACKLOG = (rank) => (rank == null ? null : decideLanding({ goalId: 7, kind: 'bug', filerRank: rank }).status);
|
|
67
65
|
function makeDeps(over = {}) {
|
|
68
|
-
const calls = { create: [], audit: [], count: [], persist: [] };
|
|
66
|
+
const calls = { create: [], audit: [], count: [], persist: [], suggest: [] };
|
|
69
67
|
const deps = {
|
|
70
68
|
getBuilderByDiscordUserId: async () => (over.builder === undefined ? null : over.builder),
|
|
71
69
|
countRecentDiscordBugFilings: async (id, since) => {
|
|
@@ -73,7 +71,29 @@ function makeDeps(over = {}) {
|
|
|
73
71
|
if (over.countThrows) throw new Error('count boom');
|
|
74
72
|
return over.recent ?? 0;
|
|
75
73
|
},
|
|
76
|
-
|
|
74
|
+
// task 1003067: the vector files through ideas.capture now. `calls.create`
|
|
75
|
+
// keeps its name — it is still "what this vector asked to be created" — but
|
|
76
|
+
// the argument shape is a CAPTURE, and the landing status comes back from the
|
|
77
|
+
// matrix rather than being posted in.
|
|
78
|
+
suggestGoalForCapture: async (draft) => {
|
|
79
|
+
calls.suggest.push(draft);
|
|
80
|
+
if (over.suggestThrows) throw new Error('suggest boom');
|
|
81
|
+
return over.goal === undefined ? { id: 7, title: 'A goal', version_id: 'BONGOS-V1', score: 4 } : over.goal;
|
|
82
|
+
},
|
|
83
|
+
captureIdea: async (a) => {
|
|
84
|
+
calls.create.push(a);
|
|
85
|
+
if (over.createThrows) throw new Error('db boom');
|
|
86
|
+
// routeIdeaToGoal refuses a landing by THROWING, which rolls the idea row
|
|
87
|
+
// back with it — the case the goal-dropping retry exists for.
|
|
88
|
+
if (over.refuseRouted && a.goalId != null) throw { code: 'ROUTE_NOT_AUTHORIZED', gate: 'protected_scope' };
|
|
89
|
+
const routes = a.goalId != null && READY_OR_BACKLOG(a.filerRank) !== null;
|
|
90
|
+
return {
|
|
91
|
+
id: over.ideaId ?? 900,
|
|
92
|
+
routing: routes
|
|
93
|
+
? { routed: true, task_id: over.taskId ?? 555, goal_id: a.goalId, status: READY_OR_BACKLOG(a.filerRank) }
|
|
94
|
+
: { routed: false, reason: 'no_goal' },
|
|
95
|
+
};
|
|
96
|
+
},
|
|
77
97
|
insertAuditLog: async (a) => { calls.audit.push(a); if (over.auditThrows) throw new Error('audit boom'); return { id: 1 }; },
|
|
78
98
|
persistImages: async (urls, opts) => {
|
|
79
99
|
calls.persist.push({ urls, opts });
|
|
@@ -90,13 +110,16 @@ await t('recordInboundBug: linked Metic → filed at ready, kind=bug, attributed
|
|
|
90
110
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'Boats vanish at the dock', messageId: 'm1' }, deps, CFG);
|
|
91
111
|
assert.equal(r.outcome, 'filed');
|
|
92
112
|
assert.equal(r.taskId, 555);
|
|
113
|
+
assert.equal(r.ideaId, 900, 'the idea row is the attribution the old path threw away');
|
|
93
114
|
assert.equal(r.status, 'ready');
|
|
94
|
-
assert.equal(calls.create[0].kind, 'bug');
|
|
95
|
-
assert.equal(calls.create[0].
|
|
96
|
-
assert.equal(calls.create[0].
|
|
97
|
-
assert.equal(calls.create[0].
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
assert.equal(calls.create[0].kind, 'bug', 'the channel IS the classification');
|
|
116
|
+
assert.equal(calls.create[0].filerRank, 'metic', 'the LIVE rank is handed to the matrix');
|
|
117
|
+
assert.equal(calls.create[0].capturedBy, 9, 'and the reporter is attached to the capture');
|
|
118
|
+
assert.equal(calls.create[0].goalId, 7, 'the suggester found it a home');
|
|
119
|
+
// task 1003067: no versionId is passed AT ALL. It used to be the hardcoded
|
|
120
|
+
// 'GDS-V3' — a shipped version, which is idea 1000771. The goal carries it now.
|
|
121
|
+
assert.equal(calls.create[0].versionId, undefined, 'this vector no longer names a version');
|
|
122
|
+
assert.match(calls.create[0].bodyMd, /reported from Discord #bugs by mona/);
|
|
100
123
|
assert.equal(calls.audit[0].surface, 'discord_bot');
|
|
101
124
|
assert.equal(calls.audit[0].responseStatus, 201);
|
|
102
125
|
assert.equal(calls.audit[0].builderId, 9);
|
|
@@ -106,18 +129,74 @@ await t('recordInboundBug: linked Xenos → filed at backlog (needs confirm)', a
|
|
|
106
129
|
const { deps, calls } = makeDeps({ builder: { id: 4, github_login: 'newbie', rank: 'xenos' } });
|
|
107
130
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'Typo on the welcome sign', messageId: 'm2' }, deps, CFG);
|
|
108
131
|
assert.equal(r.outcome, 'filed');
|
|
109
|
-
assert.equal(r.status, 'backlog');
|
|
110
|
-
assert.equal(calls.create[0].
|
|
132
|
+
assert.equal(r.status, 'backlog', 'a sub-Metic report waits for a nod, as before');
|
|
133
|
+
assert.equal(calls.create[0].filerRank, 'xenos');
|
|
111
134
|
});
|
|
112
135
|
|
|
113
|
-
await t('recordInboundBug: UNLINKED author →
|
|
136
|
+
await t('recordInboundBug: UNLINKED author → an inbox idea, never a task [changed by 1003067]', async () => {
|
|
137
|
+
// WAS: dropped entirely ('unlinked'), because this path minted claimable work
|
|
138
|
+
// and ADR 0047 would not let an unaccountable author near that. It no longer
|
|
139
|
+
// mints work, so the report is kept — as an idea row with no author and NO
|
|
140
|
+
// GOAL, which is exactly what #ideas has always done for the same person.
|
|
141
|
+
// The security property survives structurally: no goal ⇒ the matrix cannot
|
|
142
|
+
// route ⇒ no task exists to claim.
|
|
114
143
|
const { deps, calls } = makeDeps({ builder: null });
|
|
115
144
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'something is broken here', messageId: 'm3' }, deps, CFG);
|
|
116
|
-
assert.equal(r.outcome, '
|
|
117
|
-
assert.equal(r.
|
|
145
|
+
assert.equal(r.outcome, 'filed');
|
|
146
|
+
assert.equal(r.ideaId, 900, 'the report is recorded rather than silently dropped');
|
|
147
|
+
assert.equal(r.taskId, null, 'and it is NOT work');
|
|
148
|
+
assert.equal(r.status, null);
|
|
149
|
+
assert.equal(calls.create[0].capturedBy, null, 'unattributed, and honestly so');
|
|
150
|
+
assert.equal(calls.create[0].goalId, null, 'no home is offered to an unlinked author');
|
|
151
|
+
assert.equal(calls.suggest.length, 0, 'we do not even ask for one');
|
|
152
|
+
assert.match(calls.create[0].bodyMd, /unlinked author/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('recordInboundBug: an unlinked author is NOT rate-limited by builder id (there is none)', async () => {
|
|
156
|
+
const { deps, calls } = makeDeps({ builder: null, recent: 999 });
|
|
157
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'another unlinked report', messageId: 'm3b' }, deps, CFG);
|
|
158
|
+
assert.equal(r.outcome, 'filed', 'the per-builder cap cannot express an author with no builder row');
|
|
159
|
+
assert.equal(calls.count.length, 0, 'so it is not consulted');
|
|
160
|
+
assert.equal(r.taskId, null, 'the bound that matters is that none of it becomes work');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('recordInboundBug: a suggester failure still files — to the inbox [1003067]', async () => {
|
|
164
|
+
// A suggester that throws must not lose the bug report. No goal ⇒ inbox row.
|
|
165
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, suggestThrows: true });
|
|
166
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'the suggester is down', messageId: 'm3c' }, deps, CFG);
|
|
167
|
+
assert.equal(r.outcome, 'error', 'it surfaces as an error rather than pretending to have filed');
|
|
118
168
|
assert.equal(calls.create.length, 0);
|
|
119
169
|
});
|
|
120
170
|
|
|
171
|
+
await t('recordInboundBug: a REFUSED goal drops the guess and still files the report [1003067]', async () => {
|
|
172
|
+
// The suggester picks the home; the goal can still refuse it (protected scope, a
|
|
173
|
+
// scope-wall verdict, an archive mid-flight). Because routing is one transaction
|
|
174
|
+
// that refusal takes the idea row with it — so a goal WE guessed at would lose a
|
|
175
|
+
// report the reporter did nothing wrong to file. Drop the guess, keep the report.
|
|
176
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, refuseRouted: true });
|
|
177
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'a bug whose words match a protected goal', messageId: 'm7' }, deps, CFG);
|
|
178
|
+
assert.equal(r.outcome, 'filed', 'the report survives the refusal');
|
|
179
|
+
assert.equal(calls.create.length, 2, 'one routed attempt, then one homeless retry');
|
|
180
|
+
assert.equal(calls.create[0].goalId, 7, 'the first carried the suggested home');
|
|
181
|
+
assert.equal(calls.create[1].goalId, null, 'the retry drops it');
|
|
182
|
+
assert.equal(r.taskId, null, 'and it lands in the inbox, where triage will see it');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t('recordInboundBug: a refusal with NO goal to drop is a real error, not a retry loop', async () => {
|
|
186
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, goal: null, refuseRouted: true, createThrows: true });
|
|
187
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'the database is simply down', messageId: 'm8' }, deps, CFG);
|
|
188
|
+
assert.equal(r.outcome, 'error', 'nothing to drop ⇒ surface it rather than retrying forever');
|
|
189
|
+
assert.equal(calls.create.length, 1, 'exactly one attempt');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await t('recordInboundBug: NO confident goal → the report still lands, in the inbox', async () => {
|
|
193
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, goal: null });
|
|
194
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'a bug matching no goal at all', messageId: 'm3d' }, deps, CFG);
|
|
195
|
+
assert.equal(r.outcome, 'filed');
|
|
196
|
+
assert.equal(r.taskId, null, 'no home ⇒ no task, which is the homeless-inbox contract (C2)');
|
|
197
|
+
assert.equal(calls.create[0].goalId, null);
|
|
198
|
+
});
|
|
199
|
+
|
|
121
200
|
await t('recordInboundBug: over rate-limit → ignored + audited 429, nothing created', async () => {
|
|
122
201
|
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, recent: 5 });
|
|
123
202
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'yet another bug report', messageId: 'm4' }, deps, CFG);
|
|
@@ -170,7 +249,7 @@ await t('recordInboundBug: audit failure does NOT undo the filing', async () =>
|
|
|
170
249
|
assert.equal(calls.create.length, 1);
|
|
171
250
|
});
|
|
172
251
|
|
|
173
|
-
await t('recordInboundBug:
|
|
252
|
+
await t('recordInboundBug: a capture error → {error}, never throws', async () => {
|
|
174
253
|
const { deps } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, createThrows: true });
|
|
175
254
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'valid bug body here', messageId: 'm' }, deps, CFG);
|
|
176
255
|
assert.equal(r.outcome, 'error');
|
|
@@ -202,8 +281,8 @@ await t('recordInboundBug: text + images → persists images + embeds durable li
|
|
|
202
281
|
assert.equal(r.images, 1);
|
|
203
282
|
assert.equal(calls.persist.length, 1);
|
|
204
283
|
assert.deepEqual(calls.persist[0].urls, ['https://cdn.discordapp.com/a.png']);
|
|
205
|
-
assert.match(calls.create[0].
|
|
206
|
-
assert.match(calls.create[0].
|
|
284
|
+
assert.match(calls.create[0].bodyMd, /Attachments \(1\)/);
|
|
285
|
+
assert.match(calls.create[0].bodyMd, /!\[attachment 1\]\(\/api\/gds\/bug-attachments\//);
|
|
207
286
|
});
|
|
208
287
|
|
|
209
288
|
await t('recordInboundBug: image-only (empty text) → filed with screenshot title', async () => {
|
|
@@ -225,7 +304,7 @@ await t('recordInboundBug: image persist failure → still files (without images
|
|
|
225
304
|
);
|
|
226
305
|
assert.equal(r.outcome, 'filed');
|
|
227
306
|
assert.equal(r.images, 0);
|
|
228
|
-
assert.doesNotMatch(calls.create[0].
|
|
307
|
+
assert.doesNotMatch(calls.create[0].bodyMd, /Attachments/);
|
|
229
308
|
});
|
|
230
309
|
|
|
231
310
|
await t('recordInboundBug: all images refused (stored empty) → filed, no Attachments block', async () => {
|
|
@@ -236,7 +315,7 @@ await t('recordInboundBug: all images refused (stored empty) → filed, no Attac
|
|
|
236
315
|
);
|
|
237
316
|
assert.equal(r.outcome, 'filed');
|
|
238
317
|
assert.equal(r.images, 0);
|
|
239
|
-
assert.doesNotMatch(calls.create[0].
|
|
318
|
+
assert.doesNotMatch(calls.create[0].bodyMd, /Attachments/);
|
|
240
319
|
});
|
|
241
320
|
|
|
242
321
|
// ---- combineParts ----------------------------------------------------------
|