@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.
- package/RELEASE_NOTES.md +6 -6
- package/app/api/connectors/[id]/settings/route.ts +31 -37
- package/app/api/connectors/[id]/test/route.ts +260 -0
- package/app/api/connectors/install-local/route.ts +211 -0
- package/app/api/connectors/marketplace/route.ts +79 -0
- package/app/api/connectors/route.ts +41 -46
- package/app/api/jobs/route.ts +4 -0
- package/app/api/skills/install-local/route.ts +282 -0
- package/components/ConnectorsPanel.tsx +526 -211
- package/components/SettingsModal.tsx +1 -0
- package/components/SkillsPanel.tsx +42 -1
- package/lib/agents/claude-adapter.ts +4 -0
- package/lib/agents/types.ts +6 -0
- package/lib/chat/agent-loop.ts +13 -22
- package/lib/chat/protocols/http.ts +1 -1
- package/lib/chat/protocols/shell.ts +1 -1
- package/lib/chat/tool-dispatcher.ts +20 -20
- package/lib/connectors/migration.ts +110 -0
- package/lib/connectors/registry.ts +328 -0
- package/lib/connectors/sync.ts +305 -0
- package/lib/connectors/types.ts +253 -0
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/17-connectors.md +241 -189
- package/lib/help-docs/21-build-connector.md +314 -0
- package/lib/help-docs/CLAUDE.md +4 -2
- package/lib/init.ts +25 -0
- package/lib/jobs/dispatcher.ts +28 -8
- package/lib/jobs/scheduler.ts +66 -6
- package/lib/jobs/store.ts +51 -2
- package/lib/jobs/types.ts +32 -0
- package/lib/pipeline-scheduler.ts +3 -2
- package/lib/pipeline.ts +137 -15
- package/lib/plugins/registry.ts +9 -42
- package/lib/plugins/types.ts +4 -129
- package/lib/settings.ts +7 -0
- package/lib/skills.ts +27 -1
- package/lib/task-manager.ts +62 -2
- package/package.json +4 -1
- package/src/core/db/database.ts +4 -0
- package/lib/builtin-plugins/github-api.yaml +0 -93
- package/lib/builtin-plugins/gitlab.yaml +0 -860
- package/lib/builtin-plugins/mantis.probe.js +0 -176
- package/lib/builtin-plugins/mantis.yaml +0 -964
- package/lib/builtin-plugins/pmdb.yaml +0 -178
- 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
|
-
}
|