@dpesch/mantisbt-mcp-server 1.3.0 → 1.4.0
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/CHANGELOG.md +17 -0
- package/dist/constants.js +11 -0
- package/dist/tools/config.js +39 -12
- package/dist/tools/issues.js +50 -14
- package/package.json +1 -1
- package/scripts/record-fixtures.ts +11 -0
- package/tests/fixtures/get_issue_enums.json +67 -0
- package/tests/tools/config.test.ts +85 -39
- package/tests/tools/issues.test.ts +63 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.4.0] – 2026-03-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `get_issue_enums` now includes a `label` field in each enum entry when it differs from `name`. On localized MantisBT installations (e.g. German UI) this provides a translation table from the UI language back to the API name/id: `{"id": 10, "name": "new", "label": "Neu"}`. When `label` and `name` are identical the field is omitted to keep the output compact. The tool description also clarifies that `name` may itself be localized on installations where enum values have been customized at the database level.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `list_issues`: `assigned_to`, `reporter_id`, and `status` filters now reliably return matching issues regardless of the requested `page_size`. Previously, the tool fetched exactly `page_size` items from the API before filtering — so a small `page_size` combined with an active filter returned zero results if the matching issues were not at the top of the unfiltered list. The tool now internally fetches batches of 50 (API maximum) and scans up to 500 issues until enough matching results are found. All filter parameters are still forwarded to the API as a hint for installations that support server-side filtering.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## [1.3.1] – 2026-03-16
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `get_issue_enums` returned an empty object `{}` on MantisBT 2.x installations. The MantisBT REST API returns enum config values as pre-parsed `[{id, name, label}]` arrays, not as legacy `"id:name,..."` strings. The handler now covers both formats; the `label` field is stripped from the output.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
10
27
|
## [1.3.0] – 2026-03-16
|
|
11
28
|
|
|
12
29
|
### Added
|
package/dist/constants.js
CHANGED
|
@@ -15,6 +15,17 @@ export const RELATIONSHIP_TYPES = {
|
|
|
15
15
|
// MantisBT default status ID for "resolved". Issues with status.id strictly
|
|
16
16
|
// below this value are considered open (new/feedback/acknowledged/confirmed/assigned).
|
|
17
17
|
export const MANTIS_RESOLVED_STATUS_ID = 80;
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Issue enum config option names
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const ISSUE_ENUM_OPTIONS = [
|
|
22
|
+
'severity_enum_string',
|
|
23
|
+
'status_enum_string',
|
|
24
|
+
'priority_enum_string',
|
|
25
|
+
'resolution_enum_string',
|
|
26
|
+
'reproducibility_enum_string',
|
|
27
|
+
];
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
18
29
|
export const STATUS_NAMES = [
|
|
19
30
|
'new',
|
|
20
31
|
'feedback',
|
package/dist/tools/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { ISSUE_ENUM_OPTIONS } from '../constants.js';
|
|
2
3
|
import { getVersionHint } from '../version-hint.js';
|
|
3
4
|
function errorText(msg) {
|
|
4
5
|
const vh = getVersionHint();
|
|
@@ -65,12 +66,12 @@ Common option names:
|
|
|
65
66
|
// ---------------------------------------------------------------------------
|
|
66
67
|
server.registerTool('get_issue_enums', {
|
|
67
68
|
title: 'Get Issue Enum Values',
|
|
68
|
-
description: `Return valid ID and
|
|
69
|
+
description: `Return valid ID, name, and (if available) localized label for all issue enum fields.
|
|
69
70
|
|
|
70
71
|
Use this tool before creating or updating issues to look up the correct value
|
|
71
72
|
for severity, status, priority, resolution, or reproducibility.
|
|
72
73
|
|
|
73
|
-
Example response:
|
|
74
|
+
Example response (English installation):
|
|
74
75
|
{
|
|
75
76
|
"severity": [{"id": 10, "name": "feature"}, {"id": 50, "name": "minor"}, ...],
|
|
76
77
|
"status": [{"id": 10, "name": "new"}, {"id": 20, "name": "feedback"}, ...],
|
|
@@ -79,7 +80,30 @@ Example response:
|
|
|
79
80
|
"reproducibility": [{"id": 10, "name": "always"}, {"id": 70, "name": "have not tried"}, ...]
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
Example response (localized installation, e.g. German):
|
|
84
|
+
{
|
|
85
|
+
"status": [
|
|
86
|
+
{"id": 10, "name": "new", "label": "Neu"},
|
|
87
|
+
{"id": 20, "name": "feedback", "label": "Feedback"},
|
|
88
|
+
{"id": 30, "name": "acknowledged", "label": "Bestätigt"},
|
|
89
|
+
...
|
|
90
|
+
],
|
|
91
|
+
...
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Fields:
|
|
95
|
+
- "id" — numeric ID accepted by the API
|
|
96
|
+
- "name" — the API value to pass to create_issue or update_issue; normally English, but may be
|
|
97
|
+
localized if the installation has customized enum values in the database
|
|
98
|
+
- "label" — localized display label shown in the UI (only present when it differs from "name")
|
|
99
|
+
|
|
100
|
+
Always pass either the "id" or the "name" value to create_issue or update_issue — never the "label".
|
|
101
|
+
Use the "label" to map user input in the UI language back to the correct "name"/"id" for the API.
|
|
102
|
+
|
|
103
|
+
Note: on some installations enum values are customized at the database level. In that case "name"
|
|
104
|
+
itself may be localized (e.g. "kleinerer Fehler" instead of "minor") and no "label" will be present
|
|
105
|
+
because there is no separate English original. The "name" value returned is always the correct one
|
|
106
|
+
to use for API calls — regardless of language.`,
|
|
83
107
|
inputSchema: z.object({}),
|
|
84
108
|
annotations: {
|
|
85
109
|
readOnlyHint: true,
|
|
@@ -88,15 +112,8 @@ The "name" field is the value to pass to create_issue or update_issue.`,
|
|
|
88
112
|
},
|
|
89
113
|
}, async () => {
|
|
90
114
|
try {
|
|
91
|
-
const enumOptions = [
|
|
92
|
-
'severity_enum_string',
|
|
93
|
-
'status_enum_string',
|
|
94
|
-
'priority_enum_string',
|
|
95
|
-
'resolution_enum_string',
|
|
96
|
-
'reproducibility_enum_string',
|
|
97
|
-
];
|
|
98
115
|
const params = {};
|
|
99
|
-
|
|
116
|
+
ISSUE_ENUM_OPTIONS.forEach((opt, i) => {
|
|
100
117
|
params[`option[${i}]`] = opt;
|
|
101
118
|
});
|
|
102
119
|
const result = await client.get('config', params);
|
|
@@ -111,9 +128,19 @@ The "name" field is the value to pass to create_issue or update_issue.`,
|
|
|
111
128
|
const enums = {};
|
|
112
129
|
for (const { option, value } of configs) {
|
|
113
130
|
const key = keyMap[option];
|
|
114
|
-
if (key
|
|
131
|
+
if (!key)
|
|
132
|
+
continue;
|
|
133
|
+
if (typeof value === 'string') {
|
|
115
134
|
enums[key] = parseEnumString(value);
|
|
116
135
|
}
|
|
136
|
+
else if (Array.isArray(value)) {
|
|
137
|
+
enums[key] = value.map(({ id, name, label }) => {
|
|
138
|
+
const entry = { id, name };
|
|
139
|
+
if (label && label !== name)
|
|
140
|
+
entry.label = label;
|
|
141
|
+
return entry;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
117
144
|
}
|
|
118
145
|
return {
|
|
119
146
|
content: [{ type: 'text', text: JSON.stringify(enums, null, 2) }],
|
package/dist/tools/issues.js
CHANGED
|
@@ -40,7 +40,7 @@ export function registerIssueTools(server, client) {
|
|
|
40
40
|
// ---------------------------------------------------------------------------
|
|
41
41
|
server.registerTool('list_issues', {
|
|
42
42
|
title: 'List Issues',
|
|
43
|
-
description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.',
|
|
43
|
+
description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.\n\nNote: "assigned_to", "reporter_id", and "status" filters are applied client-side (the MantisBT REST API does not reliably support these as server-side filters). When any of these filters are active the tool automatically fetches multiple pages internally until enough matching results are found (up to 500 issues scanned). The "page" and "page_size" parameters refer to the resulting filtered list.',
|
|
44
44
|
inputSchema: z.object({
|
|
45
45
|
project_id: z.coerce.number().int().positive().optional().describe('Filter by project ID'),
|
|
46
46
|
page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
|
|
@@ -60,9 +60,7 @@ export function registerIssueTools(server, client) {
|
|
|
60
60
|
},
|
|
61
61
|
}, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status }) => {
|
|
62
62
|
try {
|
|
63
|
-
const
|
|
64
|
-
page,
|
|
65
|
-
page_size,
|
|
63
|
+
const baseParams = {
|
|
66
64
|
project_id,
|
|
67
65
|
assigned_to,
|
|
68
66
|
reporter_id,
|
|
@@ -71,19 +69,57 @@ export function registerIssueTools(server, client) {
|
|
|
71
69
|
direction,
|
|
72
70
|
select,
|
|
73
71
|
};
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
result
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
const needsClientFilter = status !== undefined || assigned_to !== undefined || reporter_id !== undefined;
|
|
73
|
+
if (!needsClientFilter) {
|
|
74
|
+
// No client-side filtering — single API call, pass pagination as-is
|
|
75
|
+
const result = await client.get('issues', { ...baseParams, page, page_size });
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Client-side filtering active: scan multiple API pages until we have
|
|
81
|
+
// enough matching results for the requested logical page.
|
|
82
|
+
const API_PAGE_SIZE = 50; // always fetch max to minimise round-trips
|
|
83
|
+
const MAX_API_PAGES = 10; // hard cap: scan at most 500 issues
|
|
84
|
+
const neededTotal = page * page_size; // need this many matches to serve page N
|
|
85
|
+
const matching = [];
|
|
86
|
+
let serverPage = 1;
|
|
87
|
+
let hasMore = true;
|
|
88
|
+
const statusLower = status?.toLowerCase();
|
|
89
|
+
while (matching.length < neededTotal && serverPage <= MAX_API_PAGES && hasMore) {
|
|
90
|
+
const batch = await client.get('issues', {
|
|
91
|
+
...baseParams,
|
|
92
|
+
page: serverPage,
|
|
93
|
+
page_size: API_PAGE_SIZE,
|
|
83
94
|
});
|
|
95
|
+
const issues = batch.issues ?? [];
|
|
96
|
+
hasMore = issues.length === API_PAGE_SIZE;
|
|
97
|
+
for (const issue of issues) {
|
|
98
|
+
if (statusLower) {
|
|
99
|
+
if (!issue.status)
|
|
100
|
+
continue;
|
|
101
|
+
if (statusLower === 'open') {
|
|
102
|
+
if ((issue.status.id ?? 0) >= MANTIS_RESOLVED_STATUS_ID)
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
else if (issue.status.name?.toLowerCase() !== statusLower) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (assigned_to !== undefined && issue.handler?.id !== assigned_to)
|
|
110
|
+
continue;
|
|
111
|
+
if (reporter_id !== undefined && issue.reporter?.id !== reporter_id)
|
|
112
|
+
continue;
|
|
113
|
+
matching.push(issue);
|
|
114
|
+
}
|
|
115
|
+
serverPage++;
|
|
84
116
|
}
|
|
117
|
+
const start = (page - 1) * page_size;
|
|
85
118
|
return {
|
|
86
|
-
content: [{
|
|
119
|
+
content: [{
|
|
120
|
+
type: 'text',
|
|
121
|
+
text: JSON.stringify({ issues: matching.slice(start, start + page_size) }, null, 2),
|
|
122
|
+
}],
|
|
87
123
|
};
|
|
88
124
|
}
|
|
89
125
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { MantisClient } from '../src/client.js';
|
|
5
|
+
import { ISSUE_ENUM_OPTIONS } from '../src/constants.js';
|
|
5
6
|
|
|
6
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
8
|
const __dirname = dirname(__filename);
|
|
@@ -124,6 +125,16 @@ async function recordFixtures(): Promise<void> {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// GET config — enum values for severity, status, priority, resolution, reproducibility
|
|
129
|
+
try {
|
|
130
|
+
const enumParams: Record<string, string> = {};
|
|
131
|
+
ISSUE_ENUM_OPTIONS.forEach((opt, i) => { enumParams[`option[${i}]`] = opt; });
|
|
132
|
+
const configResult = await client.get<unknown>('config', enumParams);
|
|
133
|
+
saveFixture('get_issue_enums.json', configResult);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('Failed to fetch config enums:', err instanceof Error ? err.message : String(err));
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
// GET projects/{id}/versions + categories
|
|
128
139
|
if (firstProjectId !== undefined) {
|
|
129
140
|
const versionProjectId = projectIdWithVersions ?? firstProjectId;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"configs": [
|
|
3
|
+
{
|
|
4
|
+
"option": "severity_enum_string",
|
|
5
|
+
"value": [
|
|
6
|
+
{ "id": 10, "name": "Feature-Wunsch", "label": "Feature-Wunsch" },
|
|
7
|
+
{ "id": 20, "name": "Trivial", "label": "Trivial" },
|
|
8
|
+
{ "id": 30, "name": "Fehler im Text", "label": "Fehler im Text" },
|
|
9
|
+
{ "id": 40, "name": "Unschönheit", "label": "Unschönheit" },
|
|
10
|
+
{ "id": 50, "name": "kleinerer Fehler", "label": "kleinerer Fehler" },
|
|
11
|
+
{ "id": 60, "name": "schwerer Fehler", "label": "schwerer Fehler" },
|
|
12
|
+
{ "id": 70, "name": "Absturz", "label": "Absturz" },
|
|
13
|
+
{ "id": 80, "name": "Blocker", "label": "Blocker" },
|
|
14
|
+
{ "id": 200, "name": "Technische Schuld", "label": "Technische Schuld" },
|
|
15
|
+
{ "id": 210, "name": "Wartung", "label": "Wartung" }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"option": "status_enum_string",
|
|
20
|
+
"value": [
|
|
21
|
+
{ "id": 10, "name": "new", "label": "neu" },
|
|
22
|
+
{ "id": 20, "name": "feedback", "label": "Rückmeldung" },
|
|
23
|
+
{ "id": 30, "name": "acknowledged", "label": "anerkannt" },
|
|
24
|
+
{ "id": 40, "name": "confirmed", "label": "bestätigt" },
|
|
25
|
+
{ "id": 50, "name": "assigned", "label": "zugewiesen" },
|
|
26
|
+
{ "id": 80, "name": "resolved", "label": "erledigt" },
|
|
27
|
+
{ "id": 90, "name": "closed", "label": "geschlossen" }
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"option": "priority_enum_string",
|
|
32
|
+
"value": [
|
|
33
|
+
{ "id": 10, "name": "none", "label": "keine" },
|
|
34
|
+
{ "id": 20, "name": "low", "label": "niedrig" },
|
|
35
|
+
{ "id": 30, "name": "normal", "label": "normal" },
|
|
36
|
+
{ "id": 40, "name": "high", "label": "hoch" },
|
|
37
|
+
{ "id": 50, "name": "urgent", "label": "dringend" },
|
|
38
|
+
{ "id": 60, "name": "immediate", "label": "sofort" }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"option": "resolution_enum_string",
|
|
43
|
+
"value": [
|
|
44
|
+
{ "id": 10, "name": "open", "label": "offen" },
|
|
45
|
+
{ "id": 20, "name": "fixed", "label": "erledigt" },
|
|
46
|
+
{ "id": 30, "name": "reopened", "label": "wiedereröffnet" },
|
|
47
|
+
{ "id": 40, "name": "unable to duplicate", "label": "nicht reproduzierbar" },
|
|
48
|
+
{ "id": 50, "name": "not fixable", "label": "unlösbar" },
|
|
49
|
+
{ "id": 60, "name": "duplicate", "label": "doppelt" },
|
|
50
|
+
{ "id": 70, "name": "not a bug", "label": "keine Änderung notwendig" },
|
|
51
|
+
{ "id": 80, "name": "suspended", "label": "aufgeschoben" },
|
|
52
|
+
{ "id": 90, "name": "wont fix", "label": "wird nicht behoben" }
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"option": "reproducibility_enum_string",
|
|
57
|
+
"value": [
|
|
58
|
+
{ "id": 10, "name": "always", "label": "immer" },
|
|
59
|
+
{ "id": 30, "name": "sometimes", "label": "manchmal" },
|
|
60
|
+
{ "id": 50, "name": "random", "label": "zufällig" },
|
|
61
|
+
{ "id": 70, "name": "have not tried", "label": "nicht getestet" },
|
|
62
|
+
{ "id": 90, "name": "unable to duplicate", "label": "nicht reproduzierbar" },
|
|
63
|
+
{ "id": 100, "name": "N/A", "label": "N/A" }
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
type EnumResult = Record<string, Array<{ id: number; name: string; label?: string }>>;
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
2
7
|
import { MantisClient } from '../../src/client.js';
|
|
3
8
|
import { MetadataCache } from '../../src/cache.js';
|
|
4
9
|
import { registerConfigTools } from '../../src/tools/config.js';
|
|
5
10
|
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
6
11
|
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
const fixturesDir = join(__dirname, '..', 'fixtures');
|
|
15
|
+
|
|
7
16
|
// ---------------------------------------------------------------------------
|
|
8
|
-
//
|
|
17
|
+
// Fixtures
|
|
9
18
|
// ---------------------------------------------------------------------------
|
|
10
19
|
|
|
11
|
-
const
|
|
20
|
+
const enumFixture = JSON.parse(
|
|
21
|
+
readFileSync(join(fixturesDir, 'get_issue_enums.json'), 'utf-8')
|
|
22
|
+
) as { configs: Array<{ option: string; value: Array<{ id: number; name: string; label: string }> }> };
|
|
23
|
+
|
|
24
|
+
// Legacy: some MantisBT versions return value as a comma-separated "id:name" string
|
|
25
|
+
const ENUM_FIXTURE_STRING = {
|
|
12
26
|
configs: [
|
|
13
|
-
{ option: 'severity_enum_string',
|
|
14
|
-
{ option: 'status_enum_string',
|
|
15
|
-
{ option: 'priority_enum_string',
|
|
16
|
-
{ option: 'resolution_enum_string',
|
|
17
|
-
{ option: 'reproducibility_enum_string', value: '10:always,
|
|
27
|
+
{ option: 'severity_enum_string', value: '10:feature,50:minor,80:block' },
|
|
28
|
+
{ option: 'status_enum_string', value: '10:new,80:resolved,90:closed' },
|
|
29
|
+
{ option: 'priority_enum_string', value: '10:none,30:normal,60:immediate' },
|
|
30
|
+
{ option: 'resolution_enum_string', value: '10:open,20:fixed' },
|
|
31
|
+
{ option: 'reproducibility_enum_string', value: '10:always,70:have not tried' },
|
|
18
32
|
],
|
|
19
33
|
};
|
|
20
34
|
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Setup
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
21
39
|
let mockServer: MockMcpServer;
|
|
22
40
|
let client: MantisClient;
|
|
23
41
|
let cache: MetadataCache;
|
|
@@ -43,49 +61,77 @@ describe('get_issue_enums', () => {
|
|
|
43
61
|
expect(mockServer.hasToolRegistered('get_issue_enums')).toBe(true);
|
|
44
62
|
});
|
|
45
63
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
describe('mit Array-Format (MantisBT 2.x)', () => {
|
|
65
|
+
let parsed: EnumResult;
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
69
|
+
const result = await mockServer.callTool('get_issue_enums', {});
|
|
70
|
+
parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('gibt strukturierte Enum-Arrays für alle 5 Felder zurück', () => {
|
|
74
|
+
expect(Array.isArray(parsed.severity)).toBe(true);
|
|
75
|
+
expect(Array.isArray(parsed.status)).toBe(true);
|
|
76
|
+
expect(Array.isArray(parsed.priority)).toBe(true);
|
|
77
|
+
expect(Array.isArray(parsed.resolution)).toBe(true);
|
|
78
|
+
expect(Array.isArray(parsed.reproducibility)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('id und name korrekt für alle Felder', () => {
|
|
82
|
+
expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 50, name: 'kleinerer Fehler' }));
|
|
83
|
+
expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 80, name: 'Blocker' }));
|
|
84
|
+
expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 200, name: 'Technische Schuld' }));
|
|
85
|
+
expect(parsed.status).toContainEqual(expect.objectContaining({ id: 10, name: 'new' }));
|
|
86
|
+
expect(parsed.status).toContainEqual(expect.objectContaining({ id: 80, name: 'resolved' }));
|
|
87
|
+
expect(parsed.priority).toContainEqual(expect.objectContaining({ id: 30, name: 'normal' }));
|
|
88
|
+
expect(parsed.reproducibility).toContainEqual(expect.objectContaining({ id: 70, name: 'have not tried' }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('label wird ausgegeben wenn er sich von name unterscheidet (lokalisierte Installation)', () => {
|
|
92
|
+
// status: name="new", label="neu" → label muss im Output enthalten sein
|
|
93
|
+
expect(parsed.status).toContainEqual({ id: 10, name: 'new', label: 'neu' });
|
|
94
|
+
expect(parsed.status).toContainEqual({ id: 80, name: 'resolved', label: 'erledigt' });
|
|
95
|
+
// priority: name="none", label="keine" → label muss im Output enthalten sein
|
|
96
|
+
expect(parsed.priority).toContainEqual({ id: 10, name: 'none', label: 'keine' });
|
|
97
|
+
// reproducibility: name="always", label="immer" → label muss im Output enthalten sein
|
|
98
|
+
expect(parsed.reproducibility).toContainEqual({ id: 10, name: 'always', label: 'immer' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('label wird weggelassen wenn er identisch mit name ist', () => {
|
|
102
|
+
// severity: name === label (z.B. "Feature-Wunsch" === "Feature-Wunsch") → kein label-Feld
|
|
103
|
+
const severityFirst = parsed.severity.find(e => e.id === 10)!;
|
|
104
|
+
expect(severityFirst).toEqual({ id: 10, name: 'Feature-Wunsch' });
|
|
105
|
+
expect(severityFirst).not.toHaveProperty('label');
|
|
106
|
+
|
|
107
|
+
// priority: name="normal", label="normal" → kein label-Feld
|
|
108
|
+
const priorityNormal = parsed.priority.find(e => e.id === 30)!;
|
|
109
|
+
expect(priorityNormal).toEqual({ id: 30, name: 'normal' });
|
|
110
|
+
expect(priorityNormal).not.toHaveProperty('label');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('fragt alle 5 Enum-Optionen ab', () => {
|
|
114
|
+
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
115
|
+
expect(calledUrl).toContain('severity_enum_string');
|
|
116
|
+
expect(calledUrl).toContain('status_enum_string');
|
|
117
|
+
expect(calledUrl).toContain('priority_enum_string');
|
|
118
|
+
expect(calledUrl).toContain('resolution_enum_string');
|
|
119
|
+
expect(calledUrl).toContain('reproducibility_enum_string');
|
|
120
|
+
});
|
|
59
121
|
});
|
|
60
122
|
|
|
61
|
-
it('parst
|
|
62
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(
|
|
123
|
+
it('parst String-Format (Legacy): "id:name,..."-Strings korrekt', async () => {
|
|
124
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE_STRING)));
|
|
63
125
|
|
|
64
126
|
const result = await mockServer.callTool('get_issue_enums', {});
|
|
65
127
|
|
|
66
|
-
const parsed = JSON.parse(result.content[0]!.text) as
|
|
128
|
+
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
67
129
|
|
|
68
130
|
expect(parsed.severity).toContainEqual({ id: 50, name: 'minor' });
|
|
69
|
-
expect(parsed.severity).toContainEqual({ id: 80, name: 'block' });
|
|
70
|
-
expect(parsed.status).toContainEqual({ id: 10, name: 'new' });
|
|
71
131
|
expect(parsed.status).toContainEqual({ id: 80, name: 'resolved' });
|
|
72
|
-
expect(parsed.priority).toContainEqual({ id: 30, name: 'normal' });
|
|
73
132
|
expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
|
|
74
133
|
});
|
|
75
134
|
|
|
76
|
-
it('fragt alle 5 Enum-Optionen ab', async () => {
|
|
77
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
|
|
78
|
-
|
|
79
|
-
await mockServer.callTool('get_issue_enums', {});
|
|
80
|
-
|
|
81
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
82
|
-
expect(calledUrl).toContain('severity_enum_string');
|
|
83
|
-
expect(calledUrl).toContain('status_enum_string');
|
|
84
|
-
expect(calledUrl).toContain('priority_enum_string');
|
|
85
|
-
expect(calledUrl).toContain('resolution_enum_string');
|
|
86
|
-
expect(calledUrl).toContain('reproducibility_enum_string');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
135
|
it('gibt isError: true bei API-Fehler zurück', async () => {
|
|
90
136
|
vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Access denied' })));
|
|
91
137
|
|
|
@@ -215,6 +215,69 @@ describe('list_issues', () => {
|
|
|
215
215
|
}
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
it('assigned_to scans multiple API pages (small page_size does not miss results)', async () => {
|
|
219
|
+
// Regression: previously fetched only page_size items from the API before filtering,
|
|
220
|
+
// so assigned_to:X with page_size:1 returned 0 results when user's issue was not
|
|
221
|
+
// in the first page returned by the server.
|
|
222
|
+
// Now the tool always fetches API_PAGE_SIZE=50 internally when filters are active.
|
|
223
|
+
const fixtureWithUser51: typeof listIssuesFixture = {
|
|
224
|
+
...listIssuesFixture,
|
|
225
|
+
issues: [
|
|
226
|
+
// first "slot" has a different user — previously this would have been the only
|
|
227
|
+
// item fetched with page_size:1, causing a false empty result
|
|
228
|
+
listIssuesFixture.issues.find(i => (i as { handler?: { id: number } }).handler?.id === 52)!,
|
|
229
|
+
...listIssuesFixture.issues.filter(i => (i as { handler?: { id: number } }).handler?.id === 51),
|
|
230
|
+
].filter(Boolean),
|
|
231
|
+
};
|
|
232
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(fixtureWithUser51)));
|
|
233
|
+
|
|
234
|
+
const result = await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 1 });
|
|
235
|
+
|
|
236
|
+
expect(result.isError).toBeUndefined();
|
|
237
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ handler: { id: number } }> };
|
|
238
|
+
// Must find results for user 51 despite page_size:1
|
|
239
|
+
expect(parsed.issues.length).toBeGreaterThan(0);
|
|
240
|
+
parsed.issues.forEach(issue => expect(issue.handler.id).toBe(51));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('assigned_to filters issues by handler.id (client-side)', async () => {
|
|
244
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
245
|
+
|
|
246
|
+
const result = await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 50 });
|
|
247
|
+
|
|
248
|
+
expect(result.isError).toBeUndefined();
|
|
249
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ handler: { id: number } }> };
|
|
250
|
+
const expectedCount = listIssuesFixture.issues.filter(i => (i as { handler?: { id: number } }).handler?.id === 51).length;
|
|
251
|
+
expect(parsed.issues).toHaveLength(expectedCount);
|
|
252
|
+
parsed.issues.forEach(issue => {
|
|
253
|
+
expect(issue.handler.id).toBe(51);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('assigned_to still sends the parameter to the API (server-side hint)', async () => {
|
|
258
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
259
|
+
|
|
260
|
+
await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 50 });
|
|
261
|
+
|
|
262
|
+
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
263
|
+
const url = new URL(calledUrl);
|
|
264
|
+
expect(url.searchParams.get('assigned_to')).toBe('51');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('reporter_id filters issues by reporter.id (client-side)', async () => {
|
|
268
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
269
|
+
|
|
270
|
+
const result = await mockServer.callTool('list_issues', { reporter_id: 52, page: 1, page_size: 50 });
|
|
271
|
+
|
|
272
|
+
expect(result.isError).toBeUndefined();
|
|
273
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ reporter: { id: number } }> };
|
|
274
|
+
const expectedCount = listIssuesFixture.issues.filter(i => (i as { reporter?: { id: number } }).reporter?.id === 52).length;
|
|
275
|
+
expect(parsed.issues).toHaveLength(expectedCount);
|
|
276
|
+
parsed.issues.forEach(issue => {
|
|
277
|
+
expect(issue.reporter.id).toBe(52);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
218
281
|
it('status filter is case-insensitive', async () => {
|
|
219
282
|
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
220
283
|
const resultUpper = await mockServer.callTool('list_issues', { status: 'OPEN', page: 1, page_size: 50 });
|