@aion0/forge 0.8.1 → 0.8.3

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.
Files changed (45) hide show
  1. package/RELEASE_NOTES.md +6 -6
  2. package/app/api/connectors/[id]/settings/route.ts +31 -37
  3. package/app/api/connectors/[id]/test/route.ts +260 -0
  4. package/app/api/connectors/install-local/route.ts +211 -0
  5. package/app/api/connectors/marketplace/route.ts +79 -0
  6. package/app/api/connectors/route.ts +41 -46
  7. package/app/api/jobs/route.ts +4 -0
  8. package/app/api/skills/install-local/route.ts +282 -0
  9. package/components/ConnectorsPanel.tsx +526 -211
  10. package/components/SettingsModal.tsx +1 -0
  11. package/components/SkillsPanel.tsx +42 -1
  12. package/lib/agents/claude-adapter.ts +4 -0
  13. package/lib/agents/types.ts +6 -0
  14. package/lib/chat/agent-loop.ts +13 -22
  15. package/lib/chat/protocols/http.ts +1 -1
  16. package/lib/chat/protocols/shell.ts +1 -1
  17. package/lib/chat/tool-dispatcher.ts +20 -20
  18. package/lib/connectors/migration.ts +110 -0
  19. package/lib/connectors/registry.ts +328 -0
  20. package/lib/connectors/sync.ts +305 -0
  21. package/lib/connectors/types.ts +253 -0
  22. package/lib/help-docs/00-overview.md +1 -0
  23. package/lib/help-docs/17-connectors.md +241 -189
  24. package/lib/help-docs/21-build-connector.md +314 -0
  25. package/lib/help-docs/CLAUDE.md +4 -2
  26. package/lib/init.ts +25 -0
  27. package/lib/jobs/dispatcher.ts +28 -8
  28. package/lib/jobs/scheduler.ts +66 -6
  29. package/lib/jobs/store.ts +51 -2
  30. package/lib/jobs/types.ts +32 -0
  31. package/lib/pipeline-scheduler.ts +3 -2
  32. package/lib/pipeline.ts +137 -15
  33. package/lib/plugins/registry.ts +9 -42
  34. package/lib/plugins/types.ts +4 -129
  35. package/lib/settings.ts +7 -0
  36. package/lib/skills.ts +27 -1
  37. package/lib/task-manager.ts +62 -2
  38. package/package.json +4 -1
  39. package/src/core/db/database.ts +4 -0
  40. package/lib/builtin-plugins/github-api.yaml +0 -93
  41. package/lib/builtin-plugins/gitlab.yaml +0 -860
  42. package/lib/builtin-plugins/mantis.probe.js +0 -176
  43. package/lib/builtin-plugins/mantis.yaml +0 -964
  44. package/lib/builtin-plugins/pmdb.yaml +0 -178
  45. package/lib/builtin-plugins/teams.yaml +0 -913
@@ -1,964 +0,0 @@
1
- id: mantis
2
- name: MantisBT
3
- icon: "🐞"
4
- version: "0.5.0"
5
- author: forge
6
- category: connector
7
- mode: browser-side
8
- description: |
9
- Read + comment on MantisBT bugs from the user's logged-in browser session.
10
-
11
- All tool scripts are declared in this manifest and shipped to the Forge
12
- browser extension's generic runner. No extension code changes when adding
13
- or updating Mantis tools — bump `version` and refresh.
14
-
15
- Reuses the user's PHPSESSID cookie. Targets Mantis 1.x classic URL style
16
- (bug_view_page.php?bug_id=N). Column layout in #bug_list varies per
17
- instance (Mantis "Manage Columns"); scripts build a header → index map
18
- at runtime instead of hardcoding cell positions.
19
-
20
- settings:
21
- base_url:
22
- type: string
23
- label: Mantis base URL
24
- description: "e.g. https://mantis.mycompany.com (no trailing slash)"
25
- required: true
26
- default_project:
27
- type: string
28
- label: Default project name
29
- description: "Optional — used when no project specified in queries"
30
-
31
- # Plugin-level: how the extension's runner finds / opens authenticated tabs.
32
- host_match: "{base_url}/*"
33
- login_redirect: "/login_page.php"
34
-
35
- tools:
36
- list_my_bugs:
37
- description: |
38
- List bugs visible to the current user via their saved Mantis filter
39
- ("View All Issues" page). Returns id, summary, status, severity,
40
- priority, project, last_updated.
41
- parameters:
42
- status:
43
- type: select
44
- label: Status filter (client-side)
45
- options: ["open", "closed", "all"]
46
- default: "open"
47
- limit:
48
- type: number
49
- label: Max results
50
- default: 50
51
- returns: "{ bugs: [...], total, rawCount, _page, _columnsDetected }"
52
- page:
53
- url: "{base_url}/view_all_bug_page.php"
54
- on_target: "/view_all_bug_page.php"
55
- script: |
56
- const list = document.querySelector('#bug_list');
57
- if (!list) {
58
- return { bugs: [], total: 0, _error: '#bug_list not found on ' + location.pathname };
59
- }
60
-
61
- const allRows = Array.from(list.querySelectorAll('tr'));
62
- const headerRow = allRows.find(r => r.className === 'row-category');
63
- const dataRows = allRows.filter(r => r.querySelector('input[name*="bug"]'));
64
-
65
- const headerIdx = {};
66
- if (headerRow) {
67
- Array.from(headerRow.querySelectorAll('td, th')).forEach((c, i) => {
68
- const name = (c.textContent || '').trim().toLowerCase();
69
- if (name) headerIdx[name] = i;
70
- });
71
- }
72
-
73
- const col = (cells, header) => {
74
- const idx = headerIdx[header.toLowerCase()];
75
- if (idx === undefined) return '';
76
- return (cells[idx]?.innerText || '').trim();
77
- };
78
-
79
- const STATUS_RE = /^(new|feedback|acknowledged|confirmed|assigned|open|resolved|closed|reopened)$/i;
80
- const FILTERS = {
81
- open: /^(new|feedback|acknowledged|confirmed|assigned|open|reopened)$/i,
82
- closed: /^(resolved|closed)$/i,
83
- all: /./,
84
- };
85
-
86
- const all = dataRows.map(r => {
87
- const cells = Array.from(r.querySelectorAll('td'));
88
- const checkbox = r.querySelector('input[type="checkbox"][name*="bug"]');
89
- const link = r.querySelector('a[href*="bug_view_page.php?bug_id="]');
90
- const id = checkbox?.value
91
- ? Number(checkbox.value)
92
- : Number(link?.href.match(/bug_id=(\d+)/)?.[1] || 0);
93
- if (!id) return null;
94
-
95
- // Forge quirk: "Status" column shows handler; real status under "Resolution"
96
- let status = col(cells, 'Status');
97
- if (!STATUS_RE.test(status)) {
98
- const resolution = col(cells, 'Resolution');
99
- if (STATUS_RE.test(resolution)) status = resolution;
100
- }
101
-
102
- return {
103
- id,
104
- summary: link?.getAttribute('title')?.trim() || col(cells, 'Summary'),
105
- status,
106
- priority: col(cells, 'Priority'),
107
- severity: col(cells, 'Severity') || col(cells, 'S'),
108
- keywords: col(cells, 'Keyword') || col(cells, 'Category'),
109
- reporter: col(cells, 'Reporter'),
110
- assignee: (col(cells, 'Status') || col(cells, 'Handler') || col(cells, 'Assigned To'))
111
- .replace(/^\(|\)$/g, '')
112
- .trim(),
113
- source: col(cells, 'Source'),
114
- product_version: col(cells, 'Reported Version') || col(cells, 'Product Version'),
115
- fix_schedule: col(cells, 'Fix Schedule'),
116
- last_updated: col(cells, 'Updated') || col(cells, 'Last Updated'),
117
- created_at: col(cells, 'Date Submitted') || col(cells, 'Date'),
118
- url: link?.href || '',
119
- };
120
- }).filter(Boolean);
121
-
122
- const key = (args.status || 'all').toLowerCase();
123
- const filter = FILTERS[key] || FILTERS.all;
124
- const filtered = all.filter(b => filter.test(b.status));
125
-
126
- return {
127
- bugs: filtered.slice(0, args.limit || 50),
128
- total: filtered.length,
129
- rawCount: all.length,
130
- _page: location.href,
131
- _columnsDetected: Object.keys(headerIdx),
132
- };
133
-
134
- get_bug:
135
- description: |
136
- Get full details of a single bug — all fields, notes (comments),
137
- and history. Returns a structured object with summary, description,
138
- status, resolution, priority, severity, reporter, assignee, notes,
139
- history, and the raw field map.
140
- parameters:
141
- bug_id:
142
- type: number
143
- label: Bug ID
144
- required: true
145
- returns: "{ id, summary, description, status, resolution, ..., notes: [{id, idx, author, date, body}, ...], history: [...] }"
146
- page:
147
- url: "{base_url}/bug_view_page.php?bug_id={args.bug_id}"
148
- on_target: "bug_id={args.bug_id}"
149
- script: |
150
- const detailsTitle = Array.from(document.querySelectorAll('.form-title'))
151
- .find(e => e.textContent && e.textContent.trim().startsWith('Viewing Bug'));
152
- const detailsTable = detailsTitle ? detailsTitle.closest('table') : null;
153
- if (!detailsTable) {
154
- return { _error: 'bug detail table not found on ' + location.pathname };
155
- }
156
-
157
- // Top header row pattern (row 1 = labels, row 2 = values)
158
- const rows = Array.from(detailsTable.querySelectorAll('tr'));
159
- const fields = {};
160
- if (rows[1] && rows[2]) {
161
- const labelCells = Array.from(rows[1].querySelectorAll('td'));
162
- const valueCells = Array.from(rows[2].querySelectorAll('td'));
163
- labelCells.forEach((l, i) => {
164
- const label = l.innerText.trim();
165
- const val = valueCells[i] ? valueCells[i].innerText.trim() : '';
166
- if (label && !fields[label]) fields[label] = val;
167
- });
168
- }
169
-
170
- // Walk td.category labels (the rest of the fields)
171
- Array.from(document.querySelectorAll('td.category')).forEach(label => {
172
- const labelText = label.textContent?.trim() || '';
173
- if (labelText.includes('\n') && labelText.length > 100) return;
174
- if (labelText.startsWith('Select File') || labelText === 'Bugnote') return;
175
- const v = label.nextElementSibling;
176
- if (!v || v.tagName !== 'TD' || v.classList.contains('category')) return;
177
- if (!fields[labelText]) fields[labelText] = v.innerText.trim();
178
- });
179
-
180
- // Bug notes
181
- const notesTitle = Array.from(document.querySelectorAll('.form-title'))
182
- .find(e => e.textContent && e.textContent.trim().startsWith('Bug Notes'));
183
- const notesTable = notesTitle?.closest('table');
184
- // Stable note key for Jobs dedup. Different Mantis themes put the
185
- // note id in different places — try several before falling back to
186
- // a content hash so `id` is NEVER null.
187
- function pickNoteId(row) {
188
- // 1. <tr id="c12345">
189
- const rowId = row.getAttribute('id') || '';
190
- let m = rowId.match(/^c(\d+)$/);
191
- if (m) return Number(m[1]);
192
- // 2. <a id="c12345"> / <a name="c12345"> inside the row (most themes)
193
- const anchor = row.querySelector('[id^="c"], [name^="c"]');
194
- const aId = anchor?.getAttribute('id') || anchor?.getAttribute('name') || '';
195
- m = aId.match(/^c(\d+)$/);
196
- if (m) return Number(m[1]);
197
- // 3. <a href="...#c12345"> — present in some custom skins
198
- const hashLink = row.querySelector('a[href*="#c"]');
199
- m = hashLink?.getAttribute('href')?.match(/#c(\d+)/);
200
- if (m) return Number(m[1]);
201
- // 4. bug_revision_view_page.php?bugnote_id=12345 — Mantis core links to it
202
- const editLink = row.querySelector('a[href*="bugnote_id="]');
203
- m = editLink?.getAttribute('href')?.match(/bugnote_id=(\d+)/);
204
- if (m) return Number(m[1]);
205
- return null;
206
- }
207
-
208
- // Synthetic stable key when no native id is found. SubtleCrypto would
209
- // be cleaner but isn't always allowed in MAIN world; a cheap FNV-1a
210
- // 32-bit hash is plenty to distinguish notes from each other.
211
- function fnv1a(s) {
212
- let h = 0x811c9dc5;
213
- for (let i = 0; i < s.length; i++) {
214
- h ^= s.charCodeAt(i);
215
- h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
216
- }
217
- return h.toString(16);
218
- }
219
-
220
- const notes = Array.from(notesTable?.querySelectorAll('tr.bugnote') || []).map((r, i) => {
221
- const cells = r.querySelectorAll('td');
222
- const header = cells[0]?.innerText.trim() || '';
223
- const body = cells[1]?.innerText.trim() || '';
224
- const m = header.match(/^(.*?)\n(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
225
- const author = m && m[1] ? m[1].trim() : (header.split('\n')[0] || '').trim();
226
- const date = m && m[2] ? m[2] : '';
227
-
228
- const nativeId = pickNoteId(r);
229
- // `id` is what Jobs dedups on — always populate it. Native numeric id
230
- // when available; else synthetic 'note-<hash>' from date + first 200
231
- // body chars (stable across reloads even if the note isn't edited).
232
- const id = nativeId != null
233
- ? nativeId
234
- : `note-${fnv1a(date + '' + body.slice(0, 200))}`;
235
- return {
236
- id,
237
- idx: i,
238
- author,
239
- date,
240
- body,
241
- // Surface which mechanism gave us the id for easier debugging
242
- _id_source: nativeId != null ? 'native' : 'hash(date+body)',
243
- };
244
- });
245
-
246
- // Bug history (4-column table)
247
- const historyTitle = Array.from(document.querySelectorAll('.form-title'))
248
- .find(e => e.textContent && e.textContent.trim().startsWith('Bug History'));
249
- const historyTable = historyTitle?.closest('table');
250
- const history = Array.from(historyTable?.querySelectorAll('tr') || [])
251
- .filter(r => r.querySelectorAll('td').length === 4)
252
- .slice(1)
253
- .map(r => {
254
- const c = Array.from(r.querySelectorAll('td')).map(x => x.innerText.trim());
255
- return { date: c[0], user: c[1], field: c[2], change: c[3] };
256
- });
257
-
258
- const id = Number(fields.ID || args.bug_id);
259
-
260
- return {
261
- id,
262
- summary: fields.Summary || '',
263
- description: fields.Description || '',
264
- additional_information: fields['Additional Information'] || '',
265
- status: fields.Status || '',
266
- resolution: fields.Resolution || '',
267
- priority: fields.Priority || '',
268
- severity: fields.Severity || '',
269
- reproducibility: fields.Reproducibility || '',
270
- category: fields.Category || '',
271
- keywords: fields.Keyword || '',
272
- reporter: fields.Reporter || '',
273
- assignee: fields['Assigned To'] || '',
274
- qa_assignee: fields['QA Assignee'] || '',
275
- view_status: fields['View Status'] || '',
276
- eta: fields.ETA || '',
277
- date_submitted: fields['Date Submitted'] || '',
278
- last_update: fields['Last Update'] || '',
279
- reported_version: fields['Reported Version'] || '',
280
- fix_schedule: fields['Fix Schedule'] || '',
281
- source: fields.Source || '',
282
- tag: fields.Tag || '',
283
- notes,
284
- history,
285
- url: location.href,
286
- _fields_raw: fields,
287
- };
288
-
289
- search_bugs:
290
- description: |
291
- Search bugs with optional status / project / fix-schedule filters.
292
-
293
- `query` is free-text on summary + description ONLY. DO NOT pass
294
- pseudo-syntax like `status:assigned` / `project:Foo` / a version
295
- number inside the query — those become literal search terms and
296
- match nothing.
297
-
298
- Use these dedicated params instead:
299
- - status — by Mantis status (open / new / assigned / ...)
300
- - project_id — by Mantis project (numeric id from list_projects)
301
- - fix_schedule — Fix Schedule text — uses Mantis search_fixforecast
302
- URL param (server-side; fast). Example "7.6.7".
303
- - assignee — Username (e.g. "jdoe"), full name, or numeric user
304
- id. Resolves to handler_id URL param. Falls back
305
- to client-side substring match on the Assigned To
306
- column if the user can't be looked up.
307
- - reporter — Same matching as assignee. Becomes reporter_id.
308
- - qa_assignee — Same matching as assignee. Becomes qa_assignee_id.
309
- (Some Mantis installs expose QA as a first-class
310
- field, not a custom field — this is one of them.)
311
- - extra_params — Object of { name: value } passed verbatim to
312
- Mantis filter URL. Use for fields we don't
313
- model directly: bug_type, dev_status,
314
- review_required, escalated_by, eco_checked_in,
315
- feedback_requested, bug_type, search_type,
316
- multicheckbox_tags[], etc. Discover them on
317
- your Mantis filter page (DevTools → form fields).
318
- parameters:
319
- query:
320
- type: string
321
- label: Search query (free text, matches summary + description)
322
- required: false
323
- status:
324
- type: string
325
- label: |
326
- Status filter. Accepts a single name or comma list:
327
- new, feedback, acknowledged, confirmed, assigned,
328
- resolved, closed, reopened.
329
- Aliases:
330
- "open" → new,feedback,acknowledged,confirmed,assigned (everything <80)
331
- "all" → no filter
332
- default: "open"
333
- project_id:
334
- type: number
335
- label: "Mantis project id (numeric — get from list_projects). Omit = all projects user can see."
336
- required: false
337
- fix_schedule:
338
- type: string
339
- label: |
340
- Fix Schedule custom field (e.g. "7.6.7"). Case-insensitive
341
- substring match. Walks Mantis pagination until `limit` matches
342
- found or the scan cap is reached.
343
- required: false
344
- assignee:
345
- type: string
346
- label: |
347
- Assignee — accepts username ("jdoe"), full name ("Jane Doe"),
348
- or numeric user id ("8806"). Looked up via the filter form's
349
- handler_id dropdown that Mantis already serves on view_all_bug_page.
350
- Resolves to URL param handler_id=<id> (server-side filter, fast).
351
- Falls back to client-side substring match on the Assigned To
352
- column if lookup fails.
353
- required: false
354
- reporter:
355
- type: string
356
- label: |
357
- Reporter — same matching as assignee. Resolves to reporter_id.
358
- required: false
359
- qa_assignee:
360
- type: string
361
- label: |
362
- QA Assignee — same matching as assignee. Resolves to
363
- qa_assignee_id (treated as a first-class field on customized
364
- Mantis installs that expose it directly rather than as a
365
- custom field).
366
- required: false
367
- extra_params:
368
- type: object
369
- label: |
370
- Raw Mantis filter URL params. Useful for install-specific custom
371
- fields we don't model directly. Examples (your install may have
372
- others — inspect the filter form in DevTools to find names):
373
- bug_type (Regression / Suggestion / ...)
374
- dev_status (discuss / pending / fixme-hi / ...)
375
- review_required (any / v1-v5)
376
- feedback_requested (Yes / No / any)
377
- eco_checked_in (Yes / No)
378
- escalated_by (numeric user id)
379
- search_type (1=AND, 2=OR)
380
- multicheckbox_tags[] (tag id)
381
- submit_starttime, submit_endtime
382
- start_reported_version, end_reported_version
383
- css_tickets_cnt, etc.
384
- Pass as { "bug_type": "Regression", "dev_status": "pending" }.
385
- required: false
386
- limit:
387
- type: number
388
- default: 25
389
- returns: "{ bugs: [{id, summary, status, priority, severity, reporter, assignee, qa_assignee, fix_schedule, …}], total (returned), total_matching (server-side total across all pages, from 'Viewing Bugs (a - b / N)'), rawCount, scanned, pages_walked, query, status, project_id, fix_schedule, assignee, reporter, qa_assignee, _filter_url, _page }"
390
- page:
391
- url: "{base_url}/view_all_bug_page.php"
392
- on_target: "/view_all_bug_page.php"
393
- script: |
394
- // Status enum probed live from a customized Mantis filter form.
395
- // Note: 'reopened' (70) is NOT in every install — the probed one
396
- // dropped it. If your Mantis is standard MantisBT (with reopened),
397
- // add 70 here.
398
- const STATUS_IDS = {
399
- new: 10, feedback: 20, acknowledged: 30, confirmed: 40,
400
- assigned: 50, resolved: 80, closed: 90,
401
- };
402
- const OPEN_GROUP = ['new','feedback','acknowledged','confirmed','assigned'];
403
-
404
- function resolveStatuses(raw) {
405
- const v = String(raw || 'open').toLowerCase().trim();
406
- if (v === 'all' || v === '') return [];
407
- if (v === 'open') return OPEN_GROUP.map(n => STATUS_IDS[n]);
408
- const names = v.split(',').map(s => s.trim()).filter(Boolean);
409
- return names
410
- .map(n => STATUS_IDS[n])
411
- .filter(id => id != null);
412
- }
413
-
414
- const statusIds = resolveStatuses(args.status);
415
- const limit = Number(args.limit) || 25;
416
-
417
- // ─── User resolution (username / full name / numeric id → user_id) ─
418
- // The filter form on view_all_bug_page.php already has loaded select
419
- // elements for reporter_id / handler_id / qa_assignee_id with EVERY
420
- // Mantis user. We mine that DOM rather than making a separate lookup
421
- // call — zero extra HTTP, instant.
422
- //
423
- // Match priority for each input string:
424
- // 1. Numeric input → use as-is (skip lookup)
425
- // 2. Exact "(username)" at end of any option's label
426
- // 3. Case-insensitive substring on full label ("liz zhang")
427
- // Returns the first match's value. Null if nothing matches; caller
428
- // falls back to client-side substring filtering on the result rows.
429
- function resolveUserId(value) {
430
- const v = String(value || '').trim();
431
- if (!v) return null;
432
- if (/^\d+$/.test(v)) return v;
433
- const lv = v.toLowerCase();
434
- const sels = document.querySelectorAll('select[name="handler_id"], select[name="reporter_id"], select[name="qa_assignee_id"]');
435
- let fallbackSubstring = null;
436
- for (const sel of sels) {
437
- for (const opt of sel.options) {
438
- const id = opt.value;
439
- if (!/^\d+$/.test(id)) continue;
440
- const label = (opt.textContent || '').toLowerCase().trim();
441
- const m = label.match(/\(([a-z0-9_.-]+)\)\s*$/);
442
- if (m && m[1] === lv) return id;
443
- if (!fallbackSubstring && label.includes(lv)) fallbackSubstring = id;
444
- }
445
- }
446
- return fallbackSubstring; // may be null
447
- }
448
-
449
- const handlerId = resolveUserId(args.assignee);
450
- const reporterId = resolveUserId(args.reporter);
451
- const qaAssigneeId = resolveUserId(args.qa_assignee);
452
-
453
- // Fix Schedule is now a server-side URL param (search_fixforecast),
454
- // not client-side substring. The client-side path is still kept as
455
- // a safety net only for the rare case the server-side param fails.
456
- const fixForecast = String(args.fix_schedule || '').trim();
457
- const fixNeedle = fixForecast.toLowerCase();
458
-
459
- // Multi-value client-side fallbacks for assignee/reporter/qa when user
460
- // lookup didn't resolve to an id. The unresolved input becomes the
461
- // substring needle.
462
- const splitNeedles = (raw) => String(raw || '')
463
- .toLowerCase()
464
- .split(',')
465
- .map(s => s.trim())
466
- .filter(Boolean);
467
- const assigneeNeedles = handlerId ? [] : splitNeedles(args.assignee);
468
- const reporterNeedles = reporterId ? [] : splitNeedles(args.reporter);
469
- const qaNeedles = qaAssigneeId ? [] : splitNeedles(args.qa_assignee);
470
- const hasClientFilter = assigneeNeedles.length > 0
471
- || reporterNeedles.length > 0
472
- || qaNeedles.length > 0;
473
- // Client fallback active → scan more pages.
474
- const PAGE_SIZE = hasClientFilter ? 200 : 100;
475
- const MAX_PAGES = hasClientFilter ? 10 : 2;
476
-
477
- // ─── Build & submit filter via the page's REAL form ──────────────
478
- // Some customized Mantis installs ignore URL params like \`status_id[]\`
479
- // entirely; they expect you to POST the *whole* filter form to view_all_set.php
480
- // with multicheckbox[] entries appearing in the SAME order as the
481
- // preceding show_<group>=y hidden markers (severity → status →
482
- // priority → resolution). Probed live via chrome MCP, May 2026.
483
- //
484
- // Strategy: take the form currently rendered on the page,
485
- // FormData-ify it, then override only the fields we care about.
486
- const baseDir = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}`;
487
- const filterForm = Array.from(document.querySelectorAll('form'))
488
- .find(f => /view_all_set\.php/i.test(f.action || ''));
489
- let filterUrl = baseDir + 'view_all_set.php';
490
- let appliedVia = 'form';
491
-
492
- if (!filterForm) {
493
- appliedVia = 'fallback-url';
494
- // Fallback only — Mantis without the form on the page won't honor
495
- // our filters fully, but we at least try the documented params.
496
- const params = new URLSearchParams();
497
- params.set('type', '1');
498
- params.set('temporary', 'y');
499
- params.set('print', '0');
500
- params.set('search', String(args.query || ''));
501
- params.set('per_page', String(PAGE_SIZE));
502
- if (args.project_id) params.set('project_id', String(args.project_id));
503
- if (handlerId) params.set('handler_id', handlerId);
504
- if (reporterId) params.set('reporter_id', reporterId);
505
- if (qaAssigneeId) params.set('qa_assignee_id', qaAssigneeId);
506
- if (fixForecast) params.set('search_fixforecast', fixForecast);
507
- for (const id of statusIds) params.append('status_id[]', String(id));
508
- filterUrl = baseDir + 'view_all_set.php?' + params.toString();
509
- const r = await fetch(filterUrl, { credentials: 'include' }).catch(e => ({_err: e.message}));
510
- if (r?._err) return { bugs: [], total: 0, _error: `filter set failed: ${r._err}`, _filter_url: filterUrl };
511
- } else {
512
- // Mantis renders multiple <select name="multicheckbox[]"> elements
513
- // — one per group (severity / status / priority / resolution / …).
514
- // Each is given a distinguishing \`id\` like multi_status. Earlier
515
- // attempts to align them by DOM-order positional matching with
516
- // show_X hidden fields were brittle. Set selections DIRECTLY on
517
- // each select by id, then let the browser's native FormData
518
- // serialize the form — Mantis sees exactly what it would if a
519
- // human had clicked the options.
520
- function setSelect(id, values) {
521
- const sel = filterForm.querySelector('#' + id);
522
- if (!sel) return false;
523
- for (const opt of sel.options) {
524
- opt.selected = values.includes(String(opt.value));
525
- }
526
- return true;
527
- }
528
- function setInput(name, value) {
529
- const el = filterForm.querySelector('[name="' + name + '"]');
530
- if (!el) return false;
531
- if (el.tagName === 'SELECT') {
532
- for (const opt of el.options) opt.selected = (String(opt.value) === String(value));
533
- } else {
534
- el.value = String(value);
535
- }
536
- return true;
537
- }
538
-
539
- // ── CRITICAL: reset every filter we manage to its default first.
540
- //
541
- // Mantis stores the filter in the user's PHP session. If a previous
542
- // Job set handler_id=8806 ("zliu") and this Job's search doesn't
543
- // mention assignee, Mantis will STILL filter by handler_id=8806 —
544
- // leaking state across jobs that share a browser session. This
545
- // produced the exact "I configured fix_schedule=7.6.7 and got
546
- // results pre-filtered to my user" symptom.
547
- //
548
- // Resetting before applying means each Job's source_input is the
549
- // ENTIRE filter spec: anything you don't list is "any". Same
550
- // contract the user expects from a stateless tool.
551
- setInput('handler_id', 'any');
552
- setInput('reporter_id', 'any');
553
- setInput('qa_assignee_id', 'any');
554
- setInput('show_status', '');
555
- setInput('show_severity', '');
556
- setInput('show_priority', '');
557
- setInput('show_resolution', '');
558
- setInput('search', '');
559
- setInput('search_fixforecast', '');
560
- // Clear every visible multi_* select too, for cosmetics + safety in
561
- // case some Mantis version DOES read multicheckbox[].
562
- for (const id of ['multi_status', 'multi_severity', 'multi_priority', 'multi_resolution']) {
563
- const sel = filterForm.querySelector('#' + id);
564
- if (sel) for (const o of sel.options) o.selected = false;
565
- }
566
-
567
- // 1. Status — some customized Mantis installs store the active
568
- // filter in the \`show_status\` HIDDEN field (comma-separated
569
- // ids), NOT in the multi_status <select>. Probed empirically:
570
- // clicking "Apply Filter" in the UI sends e.g. show_status=10,20,50.
571
- // The visible multi_status select is just UI state that Mantis
572
- // ignores on submit. Setting show_status directly is the only
573
- // thing that actually filters.
574
- //
575
- // Pass empty string (or unset) to mean "no status filter (any)".
576
- if (statusIds.length > 0) {
577
- setInput('show_status', statusIds.join(','));
578
- // Also sync the visible select so the UI is consistent if the
579
- // user has the page open (cosmetic; not what drives the filter).
580
- setSelect('multi_status', statusIds.map(String));
581
- }
582
- // 2. Free-text + per_page + project
583
- setInput('search', String(args.query || ''));
584
- setInput('per_page', String(PAGE_SIZE));
585
- if (args.project_id) setInput('project_id', String(args.project_id));
586
- // 3. Users
587
- if (handlerId) setInput('handler_id', handlerId);
588
- if (reporterId) setInput('reporter_id', reporterId);
589
- if (qaAssigneeId) setInput('qa_assignee_id', qaAssigneeId);
590
- // 4. Fix Schedule — custom text input (some installs name it
591
- // search_fixforecast). Maps to the fix_schedule arg.
592
- if (fixForecast) setInput('search_fixforecast', fixForecast);
593
-
594
- // FormData reads the LIVE state of the form — including all the
595
- // selectedOptions / values we just set above. Hidden fields,
596
- // show_status markers and the other multicheckbox[] groups all
597
- // survive untouched.
598
- const fd = new FormData(filterForm);
599
-
600
- // 5. extra_params override — last so it can fix anything our
601
- // direct manipulation missed.
602
- const extra = args.extra_params || {};
603
- for (const [k, v] of Object.entries(extra)) {
604
- if (v == null || v === '') continue;
605
- fd.delete(k); fd.delete(k.endsWith('[]') ? k.slice(0,-2) : k + '[]');
606
- const key = Array.isArray(v) && !k.endsWith('[]') ? k + '[]' : k;
607
- if (Array.isArray(v)) v.forEach(x => fd.append(key, String(x)));
608
- else fd.set(k, String(v));
609
- }
610
-
611
- // 6. POST → Mantis stores filter in session → we fetch the list.
612
- const body = new URLSearchParams();
613
- for (const [k, v] of fd.entries()) body.append(k, typeof v === 'string' ? v : '');
614
- filterUrl = filterForm.action;
615
- const r = await fetch(filterUrl, {
616
- method: 'POST',
617
- credentials: 'include',
618
- redirect: 'follow',
619
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
620
- body: body.toString(),
621
- }).catch(e => ({ _err: e.message }));
622
- if (r?._err) return { bugs: [], total: 0, _error: `filter set failed: ${r._err}`, _filter_url: filterUrl };
623
- }
624
-
625
- const STATUS_RE = /^(new|feedback|acknowledged|confirmed|assigned|open|resolved|closed|reopened)$/i;
626
- const matched = [];
627
- let scanned = 0, pagesWalked = 0, lastUrl = '';
628
- // Hoisted so the return block (after the loop) can echo the actual
629
- // column names back for debugging missed filters.
630
- let lastHeaderIdx = {};
631
- // Total matching the filter (NOT slice-limited by \`limit\`). Mantis
632
- // Classic renders \`Viewing Bugs (1 - 50 / 151)\` above the bug list;
633
- // 151 is what callers actually want to know how many bugs are out
634
- // there. Parsed from the first page only — total is constant across
635
- // pages.
636
- let serverTotal = null;
637
-
638
- for (let page = 1; page <= MAX_PAGES; page++) {
639
- const listUrl = `${baseDir}view_all_bug_page.php?page_number=${page}`;
640
- lastUrl = listUrl;
641
- const html = await fetch(listUrl, { credentials: 'include' }).then(r => r.text())
642
- .catch(e => `__FETCH_ERR__:${e.message}`);
643
- if (html.startsWith('__FETCH_ERR__:')) {
644
- return { bugs: matched, total: matched.length, _error: html.slice('__FETCH_ERR__:'.length), _filter_url: filterUrl, _page: listUrl, scanned, pages_walked: pagesWalked };
645
- }
646
- const doc = new DOMParser().parseFromString(html, 'text/html');
647
- const list = doc.querySelector('#bug_list');
648
- if (!list) {
649
- return { bugs: matched, total: matched.length, _error: '#bug_list not found', _filter_url: filterUrl, _page: listUrl, scanned, pages_walked: pagesWalked };
650
- }
651
- const allRows = Array.from(list.querySelectorAll('tr'));
652
- const headerRow = allRows.find(r => r.className === 'row-category');
653
- const dataRows = allRows.filter(r => r.querySelector('input[name*="bug"]'));
654
- if (dataRows.length === 0) break; // out of rows
655
- pagesWalked++;
656
-
657
- // Parse Mantis pagination header: "Viewing Bugs (1 - 50 / 151)".
658
- // First page only — total is constant across pages. YAML `|` block
659
- // passes backslashes through verbatim, so regex shorthands use a
660
- // SINGLE backslash (matches the rest of the file's regex style).
661
- if (serverTotal === null) {
662
- const docText = (doc.body?.textContent || '');
663
- const m = docText.match(/Viewing\s+(?:Bugs|Issues)\s*\(\s*\d+\s*-\s*\d+\s*\/\s*(\d+)\s*\)/i);
664
- if (m) serverTotal = Number(m[1]);
665
- }
666
-
667
- const headerIdx = {};
668
- if (headerRow) {
669
- Array.from(headerRow.querySelectorAll('td, th')).forEach((c, i) => {
670
- const name = (c.textContent || '').trim().toLowerCase();
671
- if (name) headerIdx[name] = i;
672
- });
673
- }
674
- lastHeaderIdx = headerIdx;
675
- const col = (cells, header) => {
676
- const idx = headerIdx[header.toLowerCase()];
677
- if (idx === undefined) return '';
678
- return (cells[idx]?.textContent || '').replace(/\s+/g, ' ').trim();
679
- };
680
- // Fuzzy column lookup — tries any header containing one of the
681
- // probe words. Survives Mantis themes that rename "Handler" to
682
- // "Assigned", or include a sort indicator like "Assigned ↑" in the
683
- // header text. First matching header wins.
684
- const colMatch = (cells, ...probes) => {
685
- const lower = probes.map(s => s.toLowerCase());
686
- for (const [hdr, idx] of Object.entries(headerIdx)) {
687
- if (lower.some(p => hdr.includes(p))) {
688
- return (cells[idx]?.textContent || '').replace(/\s+/g, ' ').trim();
689
- }
690
- }
691
- return '';
692
- };
693
-
694
- for (const r of dataRows) {
695
- scanned++;
696
- const cells = Array.from(r.querySelectorAll('td'));
697
- const checkbox = r.querySelector('input[type="checkbox"][name*="bug"]');
698
- const link = r.querySelector('a[href*="bug_view_page.php?bug_id="]');
699
- const id = checkbox?.value
700
- ? Number(checkbox.value)
701
- : Number(link?.getAttribute('href')?.match(/bug_id=(\d+)/)?.[1] || 0);
702
- if (!id) continue;
703
- let status = col(cells, 'Status');
704
- if (!STATUS_RE.test(status)) {
705
- const resolution = col(cells, 'Resolution');
706
- if (STATUS_RE.test(resolution)) status = resolution;
707
- }
708
- const fixSchedule = col(cells, 'Fix Schedule');
709
- // Per-row filters — all client-side substring on column text.
710
- if (fixNeedle && !fixSchedule.toLowerCase().includes(fixNeedle)) continue;
711
-
712
- // colMatch picks the FIRST header that contains any probe word —
713
- // survives renames ("Handler" → "Assigned"), sort indicators
714
- // ("Assigned ↑"), trailing whitespace, etc.
715
- const assigneeText = colMatch(cells, 'handler', 'assigned', 'assignee')
716
- .replace(/^\(|\)$/g, '').trim();
717
- if (assigneeNeedles.length > 0) {
718
- const hay = assigneeText.toLowerCase();
719
- if (!assigneeNeedles.some(n => hay.includes(n))) continue;
720
- }
721
-
722
- const reporterText = colMatch(cells, 'reporter');
723
- if (reporterNeedles.length > 0) {
724
- const hay = reporterText.toLowerCase();
725
- if (!reporterNeedles.some(n => hay.includes(n))) continue;
726
- }
727
-
728
- // QA Assignee is a custom column — only present if the user's
729
- // View Issues page has it configured. If no header matches and the
730
- // user asked to filter by it, we bail on the bug (can't verify)
731
- // rather than letting it slip through unfiltered.
732
- const qaText = colMatch(cells, 'qa assignee', 'qa-assignee', 'qa_assignee');
733
- if (qaNeedles.length > 0) {
734
- if (!qaText && !Object.keys(headerIdx).some(h => h.includes('qa'))) continue;
735
- const hay = qaText.toLowerCase();
736
- if (!qaNeedles.some(n => hay.includes(n))) continue;
737
- }
738
-
739
- matched.push({
740
- id,
741
- summary: link?.getAttribute('title')?.trim() || col(cells, 'Summary'),
742
- status,
743
- priority: col(cells, 'Priority'),
744
- severity: col(cells, 'Severity') || col(cells, 'S'),
745
- keywords: col(cells, 'Keyword') || col(cells, 'Category'),
746
- reporter: reporterText,
747
- assignee: assigneeText,
748
- qa_assignee: qaText,
749
- fix_schedule: fixSchedule,
750
- product_version: col(cells, 'Reported Version') || col(cells, 'Product Version'),
751
- last_updated: col(cells, 'Updated') || col(cells, 'Last Updated'),
752
- url: link?.getAttribute('href') ? new URL(link.getAttribute('href'), location.origin).toString() : '',
753
- });
754
-
755
- if (matched.length >= limit) break;
756
- }
757
- if (matched.length >= limit) break;
758
- if (dataRows.length < PAGE_SIZE) break; // last page (short)
759
- }
760
-
761
- return {
762
- bugs: matched,
763
- // total returned (post-limit) — what the caller iterates over.
764
- total: matched.length,
765
- // total_matching — how many bugs Mantis says match the filter
766
- // across ALL pages (parsed from "Viewing Bugs (1 - 50 / 151)").
767
- // null if the parser couldn't find that header (theme variation).
768
- total_matching: serverTotal,
769
- rawCount: scanned,
770
- scanned,
771
- pages_walked: pagesWalked,
772
- query: args.query || '',
773
- status: args.status || 'open',
774
- project_id: args.project_id || null,
775
- fix_schedule: args.fix_schedule || '',
776
- assignee: args.assignee || '',
777
- reporter: args.reporter || '',
778
- qa_assignee: args.qa_assignee || '',
779
- // Echo back what resolved to user IDs vs fell through to client
780
- // substring. If a value was passed but neither resolved nor matched
781
- // anything, you'll see resolved_*=null AND zero results.
782
- resolved_handler_id: handlerId,
783
- resolved_reporter_id: reporterId,
784
- resolved_qa_assignee_id: qaAssigneeId,
785
- // Actual column headers Mantis emitted on the list page — useful
786
- // when the client-side fallback doesn't catch anything.
787
- _columns: Object.keys(lastHeaderIdx),
788
- _applied_via: appliedVia,
789
- _filter_url: filterUrl,
790
- _page: lastUrl,
791
- };
792
-
793
- list_projects:
794
- description: |
795
- List projects the current user can see. Pulled from the project_id
796
- <select> on the bug list page — manage_proj_page.php is admin-only.
797
- returns: "{ projects: [...], total }"
798
- page:
799
- url: "{base_url}/view_all_bug_page.php"
800
- on_target: "/view_all_bug_page.php"
801
- script: |
802
- const sel = document.querySelector('select[name="project_id"]');
803
- if (!sel) return { _error: 'project_id select not found on page' };
804
- const projects = Array.from(sel.options)
805
- .map(o => ({
806
- id: Number(o.value),
807
- name: (o.text || '').trim(),
808
- depth: ((o.text || '').match(/^[\s ]+/)?.[0]?.length) || 0,
809
- }))
810
- .filter(p => p.id > 0);
811
- return { projects, total: projects.length };
812
-
813
- list_attachments:
814
- description: |
815
- List the Attached Files on a single bug. Returns file_id, filename,
816
- size, uploaded_at + the canonical download URL.
817
-
818
- Implementation: scrape file_download.php?file_id=N&type=bug links
819
- from bug_view_page.php. Each file appears twice (icon-link + name-
820
- link); dedup by file_id. Filename / size / date are parsed from
821
- the row text "<filename> (<N> bytes) <YYYY-MM-DD HH:MM>".
822
- parameters:
823
- bug_id:
824
- type: number
825
- label: Bug ID
826
- required: true
827
- returns: "{ attachments: [{ file_id, filename, size, uploaded_at, download_url }], total }"
828
- page:
829
- url: "{base_url}/bug_view_page.php?bug_id={args.bug_id}"
830
- on_target: "bug_id={args.bug_id}"
831
- script: |
832
- const out = { attachments: [], total: 0 };
833
- const seen = new Set();
834
- const anchors = Array.from(document.querySelectorAll('a[href*="file_download.php?file_id="]'));
835
- for (const a of anchors) {
836
- const m = a.href.match(/file_id=(\d+)/);
837
- if (!m) continue;
838
- const fileId = Number(m[1]);
839
- if (seen.has(fileId)) continue;
840
- seen.add(fileId);
841
-
842
- // The filename text is on the OTHER anchor for the same id
843
- // (icon-link is empty). Find a sibling anchor that has text.
844
- let filename = (a.textContent || '').trim();
845
- if (!filename) {
846
- const sib = anchors.find(x => x !== a && x.href === a.href && (x.textContent || '').trim());
847
- filename = (sib?.textContent || '').trim();
848
- }
849
- // Parent text holds "filename (NN bytes) DATE" for THIS file +
850
- // possibly other files. Slice from filename onwards, parse first
851
- // "(N bytes)" + "(YYYY-MM-DD HH:MM)" after it.
852
- const parent = (a.closest('td, li, tr, p, div')?.textContent || '').replace(/\s+/g, ' ');
853
- let size = 0, uploadedAt = '';
854
- if (filename) {
855
- const idx = parent.indexOf(filename);
856
- const chunk = idx >= 0 ? parent.slice(idx + filename.length, idx + filename.length + 80) : parent;
857
- const sm = chunk.match(/\(\s*([\d,]+)\s*bytes?\s*\)/i);
858
- if (sm) size = Number(sm[1].replace(/,/g, ''));
859
- const dm = chunk.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/);
860
- if (dm) uploadedAt = dm[1];
861
- }
862
- out.attachments.push({
863
- file_id: fileId,
864
- filename,
865
- size,
866
- uploaded_at: uploadedAt,
867
- download_url: a.href,
868
- });
869
- }
870
- out.total = out.attachments.length;
871
- return out;
872
-
873
- download_attachment:
874
- description: |
875
- Download a single bug attachment as base64. Caller is responsible
876
- for checking size first (via list_attachments) — \`max_size_bytes\`
877
- hard-aborts before reading if the response Content-Length exceeds
878
- it (default 5 MB), keeping the wire and base64 cost bounded.
879
-
880
- Returns { filename, mime, size, content_b64 }. Pipelines write
881
- content_b64 → \`base64 -d\` → a real file in .attachments/.
882
- parameters:
883
- file_id:
884
- type: number
885
- label: File ID (from list_attachments)
886
- required: true
887
- max_size_bytes:
888
- type: number
889
- label: "Max size — files larger than this are SKIPPED (returns { skipped: true, size })"
890
- default: 5242880
891
- returns: "{ filename, mime, size, content_b64, skipped? }"
892
- page:
893
- url: "{base_url}/bug_view_page.php"
894
- on_target: "/bug_view_page.php"
895
- script: |
896
- // HEAD first to learn size + filename without buffering the body.
897
- const url = `${location.origin}/file_download.php?file_id=${args.file_id}&type=bug`;
898
- const headResp = await fetch(url, { method: 'HEAD', credentials: 'include' }).catch(e => ({ _err: e.message }));
899
- if (headResp && headResp._err) return { _error: 'HEAD failed: ' + headResp._err };
900
- if (!headResp.ok) return { _error: `HEAD HTTP ${headResp.status}` };
901
- const cl = Number(headResp.headers.get('content-length') || 0);
902
- const mime = headResp.headers.get('content-type') || 'application/octet-stream';
903
- // Try to pull filename from Content-Disposition.
904
- let filename = '';
905
- const cd = headResp.headers.get('content-disposition') || '';
906
- const fm = cd.match(/filename\*?=(?:UTF-8'')?"?([^"\;\n]+)"?/i);
907
- if (fm) filename = decodeURIComponent(fm[1]);
908
-
909
- const cap = Number(args.max_size_bytes) || 5242880;
910
- if (cl > cap) {
911
- return { filename, mime, size: cl, content_b64: '', skipped: true, reason: `size ${cl} > cap ${cap}` };
912
- }
913
- // GET full payload.
914
- const buf = await fetch(url, { credentials: 'include' }).then(r => r.arrayBuffer());
915
- // arraybuffer → base64. btoa wants binary string.
916
- let bin = '';
917
- const bytes = new Uint8Array(buf);
918
- const CHUNK = 0x8000;
919
- for (let i = 0; i < bytes.length; i += CHUNK) {
920
- bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
921
- }
922
- return { filename, mime, size: bytes.length, content_b64: btoa(bin) };
923
-
924
- add_comment:
925
- description: |
926
- Add a note (comment) to a bug. Requires user confirmation in the
927
- extension before sending. Mantis 1.x has no CSRF token on this
928
- form — session cookie alone authorises.
929
- destructive: true
930
- parameters:
931
- bug_id:
932
- type: number
933
- label: Bug ID
934
- required: true
935
- text:
936
- type: string
937
- label: Comment text
938
- required: true
939
- returns: "{ ok, status, finalUrl, success }"
940
- page:
941
- url: "{base_url}/bug_view_page.php?bug_id={args.bug_id}"
942
- on_target: "bug_id={args.bug_id}"
943
- script: |
944
- const body = new URLSearchParams({
945
- bug_id: String(args.bug_id),
946
- bugnote_text: args.text,
947
- });
948
- try {
949
- const r = await fetch('bugnote_add.php', {
950
- method: 'POST',
951
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
952
- body: body.toString(),
953
- credentials: 'include',
954
- redirect: 'follow',
955
- });
956
- return {
957
- ok: r.ok,
958
- status: r.status,
959
- finalUrl: r.url,
960
- success: r.ok && r.url.includes('bug_id=' + args.bug_id),
961
- };
962
- } catch (e) {
963
- return { ok: false, error: e.message };
964
- }