@zereight/mcp-gitlab 2.1.10 → 2.1.12
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/README.md +106 -104
- package/build/index.js +215 -5
- package/build/schemas.js +140 -0
- package/build/test/schema-tests.js +140 -3
- package/build/test/test-issue-description-patch.js +256 -0
- package/build/test/test-merge-request-pipelines.js +106 -0
- package/build/test/test-token-optimizations.js +1 -1
- package/build/test/test-toolset-filtering.js +7 -6
- package/build/test/utils/mock-gitlab-server.js +46 -0
- package/build/test/utils/server-launcher.js +2 -1
- package/build/tools/registry.js +61 -1
- package/build/utils/patch-helper.js +145 -0
- package/package.json +3 -2
package/build/schemas.js
CHANGED
|
@@ -248,6 +248,17 @@ export const GetPipelineSchema = z.object({
|
|
|
248
248
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
249
249
|
pipeline_id: z.coerce.string().describe("The ID of the pipeline"),
|
|
250
250
|
});
|
|
251
|
+
export const GitLabMergeRequestPipelineSchema = z.object({
|
|
252
|
+
id: z.coerce.string(),
|
|
253
|
+
sha: z.string(),
|
|
254
|
+
ref: z.string(),
|
|
255
|
+
status: z.string(),
|
|
256
|
+
project_id: z.coerce.string().optional(),
|
|
257
|
+
source: z.string().optional(),
|
|
258
|
+
created_at: z.string().optional(),
|
|
259
|
+
updated_at: z.string().optional(),
|
|
260
|
+
web_url: z.string().optional(),
|
|
261
|
+
});
|
|
251
262
|
// Schema for listing jobs in a pipeline
|
|
252
263
|
export const ListPipelineJobsSchema = z
|
|
253
264
|
.object({
|
|
@@ -514,6 +525,76 @@ export const GitLabUsersResponseSchema = z.record(z.string(), z
|
|
|
514
525
|
web_url: z.string(),
|
|
515
526
|
})
|
|
516
527
|
.nullable());
|
|
528
|
+
export const GetUserSchema = z.object({
|
|
529
|
+
user_id: z.coerce.string().describe("The ID of the user"),
|
|
530
|
+
});
|
|
531
|
+
export const GitLabUserFullSchema = z.object({
|
|
532
|
+
id: z.coerce.string(),
|
|
533
|
+
username: z.string(),
|
|
534
|
+
name: z.string(),
|
|
535
|
+
state: z.string(),
|
|
536
|
+
avatar_url: z.string().nullable(),
|
|
537
|
+
web_url: z.string(),
|
|
538
|
+
created_at: z.string(),
|
|
539
|
+
bio: z.string().nullable(),
|
|
540
|
+
location: z.string().nullable(),
|
|
541
|
+
public_email: z.string().nullable(),
|
|
542
|
+
website_url: z.string().nullable(),
|
|
543
|
+
organization: z.string().nullable(),
|
|
544
|
+
job_title: z.string().nullable(),
|
|
545
|
+
pronouns: z.string().nullable(),
|
|
546
|
+
bot: z.boolean().optional(),
|
|
547
|
+
work_information: z.string().nullable(),
|
|
548
|
+
followers: z.number().optional(),
|
|
549
|
+
following: z.number().optional(),
|
|
550
|
+
is_followed: z.boolean().optional(),
|
|
551
|
+
local_time: z.string().nullable().optional(),
|
|
552
|
+
last_sign_in_at: z.string().nullable().optional(),
|
|
553
|
+
confirmed_at: z.string().nullable().optional(),
|
|
554
|
+
last_activity_on: z.string().nullable().optional(),
|
|
555
|
+
email: z.string().nullable().optional(),
|
|
556
|
+
theme_id: z.number().nullable().optional(),
|
|
557
|
+
color_scheme_id: z.number().nullable().optional(),
|
|
558
|
+
projects_limit: z.number().nullable().optional(),
|
|
559
|
+
current_sign_in_at: z.string().nullable().optional(),
|
|
560
|
+
identities: z.array(z.object({
|
|
561
|
+
provider: z.string(),
|
|
562
|
+
extern_uid: z.string(),
|
|
563
|
+
})).optional(),
|
|
564
|
+
can_create_group: z.boolean().nullable().optional(),
|
|
565
|
+
can_create_project: z.boolean().nullable().optional(),
|
|
566
|
+
two_factor_enabled: z.boolean().nullable().optional(),
|
|
567
|
+
external: z.boolean().nullable().optional(),
|
|
568
|
+
private_profile: z.boolean().nullable().optional(),
|
|
569
|
+
is_admin: z.boolean().nullable().optional(),
|
|
570
|
+
}).passthrough();
|
|
571
|
+
export const WhoAmISchema = z.object({});
|
|
572
|
+
export const GitLabCurrentUserSchema = z.object({
|
|
573
|
+
id: z.coerce.string(),
|
|
574
|
+
username: z.string(),
|
|
575
|
+
name: z.string(),
|
|
576
|
+
state: z.string(),
|
|
577
|
+
avatar_url: z.string().nullable(),
|
|
578
|
+
web_url: z.string(),
|
|
579
|
+
created_at: z.string(),
|
|
580
|
+
bio: z.string().nullable(),
|
|
581
|
+
location: z.string().nullable(),
|
|
582
|
+
public_email: z.string().nullable(),
|
|
583
|
+
website_url: z.string().nullable(),
|
|
584
|
+
organization: z.string().nullable(),
|
|
585
|
+
job_title: z.string().nullable(),
|
|
586
|
+
email: z.string().nullable(),
|
|
587
|
+
last_sign_in_at: z.string().nullable(),
|
|
588
|
+
confirmed_at: z.string().nullable(),
|
|
589
|
+
last_activity_on: z.string().nullable(),
|
|
590
|
+
is_admin: z.boolean().optional(),
|
|
591
|
+
can_create_group: z.boolean().optional(),
|
|
592
|
+
can_create_project: z.boolean().optional(),
|
|
593
|
+
identities: z.array(z.object({
|
|
594
|
+
provider: z.string(),
|
|
595
|
+
extern_uid: z.string(),
|
|
596
|
+
})).optional(),
|
|
597
|
+
}).passthrough();
|
|
517
598
|
// Namespace related schemas
|
|
518
599
|
// Base schema for project-related operations
|
|
519
600
|
const ProjectParamsSchema = z.object({
|
|
@@ -1391,6 +1472,37 @@ export const CreateBranchSchema = ProjectParamsSchema.extend({
|
|
|
1391
1472
|
branch: z.string().describe("Name for the new branch"),
|
|
1392
1473
|
ref: z.string().optional().describe("Source branch/commit for new branch"),
|
|
1393
1474
|
});
|
|
1475
|
+
export const GetBranchSchema = ProjectParamsSchema.extend({
|
|
1476
|
+
branch_name: z.string().describe("Name of the branch"),
|
|
1477
|
+
});
|
|
1478
|
+
export const ListBranchesSchema = ProjectParamsSchema.extend({
|
|
1479
|
+
search: z.string().optional().describe("Search term to filter branches by name"),
|
|
1480
|
+
}).merge(PaginationOptionsSchema);
|
|
1481
|
+
export const DeleteBranchSchema = ProjectParamsSchema.extend({
|
|
1482
|
+
branch_name: z.string().describe("Name of the branch to delete"),
|
|
1483
|
+
});
|
|
1484
|
+
export const GitLabBranchSchema = z.object({
|
|
1485
|
+
name: z.string(),
|
|
1486
|
+
commit: z.object({
|
|
1487
|
+
id: z.string(),
|
|
1488
|
+
short_id: z.string(),
|
|
1489
|
+
title: z.string(),
|
|
1490
|
+
author_name: z.string(),
|
|
1491
|
+
author_email: z.string(),
|
|
1492
|
+
authored_date: z.string(),
|
|
1493
|
+
committer_name: z.string(),
|
|
1494
|
+
committer_email: z.string(),
|
|
1495
|
+
committed_date: z.string(),
|
|
1496
|
+
web_url: z.string(),
|
|
1497
|
+
}),
|
|
1498
|
+
merged: z.boolean(),
|
|
1499
|
+
protected: z.boolean(),
|
|
1500
|
+
developers_can_push: z.boolean(),
|
|
1501
|
+
developers_can_merge: z.boolean(),
|
|
1502
|
+
can_push: z.boolean(),
|
|
1503
|
+
default: z.boolean(),
|
|
1504
|
+
web_url: z.string().optional(),
|
|
1505
|
+
});
|
|
1394
1506
|
export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
|
|
1395
1507
|
from: z.string().describe("The base branch or commit SHA to compare from"),
|
|
1396
1508
|
to: z.string().describe("The target branch or commit SHA to compare to"),
|
|
@@ -1541,6 +1653,17 @@ export const GetMergeRequestApprovalStateSchema = ProjectParamsSchema.extend({
|
|
|
1541
1653
|
export const GetMergeRequestConflictsSchema = ProjectParamsSchema.extend({
|
|
1542
1654
|
merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
|
|
1543
1655
|
});
|
|
1656
|
+
export const ListMergeRequestPipelinesSchema = ProjectParamsSchema.extend({
|
|
1657
|
+
merge_request_iid: z
|
|
1658
|
+
.preprocess(value => (value === undefined || value === null ? value : String(value)), z
|
|
1659
|
+
.string({
|
|
1660
|
+
required_error: "merge_request_iid is required",
|
|
1661
|
+
invalid_type_error: "merge_request_iid is required",
|
|
1662
|
+
})
|
|
1663
|
+
.refine(value => value.trim().length > 0, "merge_request_iid is required")
|
|
1664
|
+
.transform(value => value.trim()))
|
|
1665
|
+
.describe("The internal ID of the merge request"),
|
|
1666
|
+
}).merge(PaginationOptionsSchema);
|
|
1544
1667
|
export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
|
|
1545
1668
|
view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
|
|
1546
1669
|
excluded_file_patterns: z
|
|
@@ -1747,6 +1870,22 @@ export const DeleteIssueSchema = z.object({
|
|
|
1747
1870
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
1748
1871
|
issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
|
|
1749
1872
|
});
|
|
1873
|
+
export const UpdateIssueDescriptionPatchSchema = z.object({
|
|
1874
|
+
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
1875
|
+
issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
|
|
1876
|
+
patch_type: z.enum(["search_replace", "unified_diff"]).describe("Type of patch format to apply"),
|
|
1877
|
+
patch: z
|
|
1878
|
+
.string()
|
|
1879
|
+
.min(1)
|
|
1880
|
+
.max(50000)
|
|
1881
|
+
.describe("The patch content to apply to the issue description"),
|
|
1882
|
+
dry_run: z.coerce.boolean().optional().describe("If true, preview changes without updating the issue"),
|
|
1883
|
+
create_note: z.coerce.boolean().optional().describe("If true, add a note summarizing the change after update"),
|
|
1884
|
+
allow_multiple: z
|
|
1885
|
+
.coerce.boolean()
|
|
1886
|
+
.optional()
|
|
1887
|
+
.describe("For search_replace: allow multiple matches to all be replaced (default: false — fail on duplicate)"),
|
|
1888
|
+
});
|
|
1750
1889
|
// Issue links related schemas
|
|
1751
1890
|
export const GitLabIssueLinkSchema = z.object({
|
|
1752
1891
|
source_issue: GitLabIssueSchema,
|
|
@@ -3141,3 +3280,4 @@ export const GetWebhookEventSchema = z
|
|
|
3141
3280
|
.refine(data => (data.project_id || data.group_id) && !(data.project_id && data.group_id), {
|
|
3142
3281
|
message: "Provide exactly one of project_id or group_id",
|
|
3143
3282
|
});
|
|
3283
|
+
export const HealthCheckSchema = z.object({});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, GetRepositoryTreeSchema } from '../schemas.js';
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema } from '../schemas.js';
|
|
3
3
|
function runGetFileContentsSchemaTests() {
|
|
4
4
|
console.log('🧪 Testing GetFileContentsSchema...');
|
|
5
5
|
const cases = [
|
|
@@ -609,6 +609,60 @@ function runGetMergeRequestSchemaTests() {
|
|
|
609
609
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
610
610
|
return { passed, failed };
|
|
611
611
|
}
|
|
612
|
+
function runListMergeRequestPipelinesSchemaTests() {
|
|
613
|
+
console.log('\n🧪 Testing ListMergeRequestPipelinesSchema...');
|
|
614
|
+
const cases = [
|
|
615
|
+
{
|
|
616
|
+
name: 'schema:list_merge_request_pipelines:minimal-required-fields',
|
|
617
|
+
input: { project_id: 'my/project', merge_request_iid: '42' },
|
|
618
|
+
expected: { project_id: 'my/project', merge_request_iid: '42' },
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: 'schema:list_merge_request_pipelines:coerces-pagination',
|
|
622
|
+
input: { project_id: 123, merge_request_iid: 42, page: '2', per_page: '10' },
|
|
623
|
+
expected: { project_id: '123', merge_request_iid: '42', page: 2, per_page: 10 },
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: 'schema:list_merge_request_pipelines:reject-missing-merge-request-iid',
|
|
627
|
+
input: { project_id: 'my/project' },
|
|
628
|
+
shouldFail: true,
|
|
629
|
+
},
|
|
630
|
+
];
|
|
631
|
+
let passed = 0;
|
|
632
|
+
let failed = 0;
|
|
633
|
+
cases.forEach(testCase => {
|
|
634
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
635
|
+
const parsed = ListMergeRequestPipelinesSchema.safeParse(testCase.input);
|
|
636
|
+
if (testCase.shouldFail) {
|
|
637
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
638
|
+
if (parsed.success)
|
|
639
|
+
result.error = 'Expected schema validation to fail';
|
|
640
|
+
}
|
|
641
|
+
else if (parsed.success) {
|
|
642
|
+
const expected = testCase.expected || {};
|
|
643
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
644
|
+
const actual = parsed.data[key];
|
|
645
|
+
return actual === value;
|
|
646
|
+
});
|
|
647
|
+
result.status = matches ? 'passed' : 'failed';
|
|
648
|
+
if (!matches)
|
|
649
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
653
|
+
}
|
|
654
|
+
if (result.status === 'passed') {
|
|
655
|
+
passed++;
|
|
656
|
+
console.log(`✅ ${result.name}`);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
failed++;
|
|
660
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
664
|
+
return { passed, failed };
|
|
665
|
+
}
|
|
612
666
|
function runGitLabMergeRequestSchemaTests() {
|
|
613
667
|
console.log('\n🧪 Testing GitLabMergeRequestSchema...');
|
|
614
668
|
const baseMergeRequest = {
|
|
@@ -1068,6 +1122,87 @@ function runGetRepositoryTreeSchemaTests() {
|
|
|
1068
1122
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1069
1123
|
return { passed, failed };
|
|
1070
1124
|
}
|
|
1125
|
+
function runGitLabUserFullSchemaTests() {
|
|
1126
|
+
console.log('🧪 Testing GitLabUserFullSchema...');
|
|
1127
|
+
const adminResponse = {
|
|
1128
|
+
id: 1,
|
|
1129
|
+
username: 'root',
|
|
1130
|
+
name: 'Administrator',
|
|
1131
|
+
state: 'active',
|
|
1132
|
+
avatar_url: 'https://gitlab.example.com/uploads/-/system/user/avatar/1/avatar.png',
|
|
1133
|
+
web_url: 'https://gitlab.example.com/root',
|
|
1134
|
+
created_at: '2012-09-22T16:50:56.000Z',
|
|
1135
|
+
bio: null,
|
|
1136
|
+
location: null,
|
|
1137
|
+
public_email: '',
|
|
1138
|
+
website_url: '',
|
|
1139
|
+
organization: null,
|
|
1140
|
+
job_title: null,
|
|
1141
|
+
pronouns: null,
|
|
1142
|
+
work_information: null,
|
|
1143
|
+
followers: 0,
|
|
1144
|
+
following: 0,
|
|
1145
|
+
is_followed: false,
|
|
1146
|
+
local_time: null,
|
|
1147
|
+
last_sign_in_at: null,
|
|
1148
|
+
confirmed_at: '2012-09-22T16:50:56.000Z',
|
|
1149
|
+
last_activity_on: '2026-05-12',
|
|
1150
|
+
email: 'admin@example.com',
|
|
1151
|
+
theme_id: 1,
|
|
1152
|
+
color_scheme_id: 1,
|
|
1153
|
+
projects_limit: 100000,
|
|
1154
|
+
current_sign_in_at: '2026-05-12T08:14:22.885Z',
|
|
1155
|
+
identities: [],
|
|
1156
|
+
can_create_group: true,
|
|
1157
|
+
can_create_project: true,
|
|
1158
|
+
two_factor_enabled: false,
|
|
1159
|
+
external: false,
|
|
1160
|
+
private_profile: false,
|
|
1161
|
+
is_admin: true,
|
|
1162
|
+
};
|
|
1163
|
+
const cases = [
|
|
1164
|
+
{
|
|
1165
|
+
name: 'schema:user_full:admin-response-no-bot',
|
|
1166
|
+
input: { ...adminResponse },
|
|
1167
|
+
shouldFail: false,
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
name: 'schema:user_full:admin-response-with-bot',
|
|
1171
|
+
input: { ...adminResponse, bot: false },
|
|
1172
|
+
shouldFail: false,
|
|
1173
|
+
},
|
|
1174
|
+
];
|
|
1175
|
+
let passed = 0;
|
|
1176
|
+
let failed = 0;
|
|
1177
|
+
cases.forEach(testCase => {
|
|
1178
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
1179
|
+
const parsed = GitLabUserFullSchema.safeParse(testCase.input);
|
|
1180
|
+
if (testCase.shouldFail) {
|
|
1181
|
+
if (parsed.success) {
|
|
1182
|
+
result.error = 'Expected schema validation to fail';
|
|
1183
|
+
}
|
|
1184
|
+
else {
|
|
1185
|
+
result.status = 'passed';
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
else if (parsed.success) {
|
|
1189
|
+
result.status = 'passed';
|
|
1190
|
+
}
|
|
1191
|
+
else {
|
|
1192
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
1193
|
+
}
|
|
1194
|
+
if (result.status === 'passed') {
|
|
1195
|
+
passed++;
|
|
1196
|
+
console.log(`✅ ${result.name}`);
|
|
1197
|
+
}
|
|
1198
|
+
else {
|
|
1199
|
+
failed++;
|
|
1200
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
1204
|
+
return { passed, failed };
|
|
1205
|
+
}
|
|
1071
1206
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1072
1207
|
const getFileContentsResult = runGetFileContentsSchemaTests();
|
|
1073
1208
|
const fileContentResult = runGitLabFileContentSchemaTests();
|
|
@@ -1075,6 +1210,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1075
1210
|
const commitStatusResult = runCommitStatusSchemaTests();
|
|
1076
1211
|
const createIssueNoteResult = runCreateIssueNoteSchemaTests();
|
|
1077
1212
|
const getMergeRequestResult = runGetMergeRequestSchemaTests();
|
|
1213
|
+
const listMergeRequestPipelinesResult = runListMergeRequestPipelinesSchemaTests();
|
|
1078
1214
|
const gitLabMergeRequestResult = runGitLabMergeRequestSchemaTests();
|
|
1079
1215
|
const emojiReactionResult = runEmojiReactionSchemaTests();
|
|
1080
1216
|
const repositorySchemaResult = runGitLabRepositorySchemaTests();
|
|
@@ -1082,8 +1218,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1082
1218
|
const listLabelsResult = runListLabelsSchemaTests();
|
|
1083
1219
|
const treeItemResult = runGitLabTreeItemSchemaTests();
|
|
1084
1220
|
const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
|
|
1085
|
-
const
|
|
1086
|
-
const
|
|
1221
|
+
const gitLabUserFullResult = runGitLabUserFullSchemaTests();
|
|
1222
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed;
|
|
1223
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed;
|
|
1087
1224
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
1088
1225
|
if (totalFailed > 0) {
|
|
1089
1226
|
process.exit(1);
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the update_issue_description_patch tool.
|
|
3
|
+
* Tests search_replace, unified_diff, dry_run, create_note, and edge cases.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, before, after } from "node:test";
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
import { launchServer, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
8
|
+
import { MockGitLabServer, findMockServerPort, } from "./utils/mock-gitlab-server.js";
|
|
9
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
10
|
+
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "../utils/patch-helper.js";
|
|
11
|
+
const MOCK_TOKEN = "glpat-patch-test-token-12345";
|
|
12
|
+
// ---- Unit tests for patch helper ----
|
|
13
|
+
describe("parseSearchReplaceBlocks", () => {
|
|
14
|
+
test("parses a single block", () => {
|
|
15
|
+
const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE");
|
|
16
|
+
assert.strictEqual(blocks.length, 1);
|
|
17
|
+
assert.strictEqual(blocks[0].search, "old text");
|
|
18
|
+
assert.strictEqual(blocks[0].replace, "new text");
|
|
19
|
+
});
|
|
20
|
+
test("parses multiple blocks", () => {
|
|
21
|
+
const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\nfirst\n=======\nfirst new\n>>>>>>> REPLACE\n<<<<<<< SEARCH\nsecond\n=======\nsecond new\n>>>>>>> REPLACE");
|
|
22
|
+
assert.strictEqual(blocks.length, 2);
|
|
23
|
+
});
|
|
24
|
+
test("handles empty content", () => {
|
|
25
|
+
const blocks = parseSearchReplaceBlocks("");
|
|
26
|
+
assert.strictEqual(blocks.length, 0);
|
|
27
|
+
});
|
|
28
|
+
test("ignores text outside blocks", () => {
|
|
29
|
+
const blocks = parseSearchReplaceBlocks("prefix\n<<<<<<< SEARCH\nx\n=======\ny\n>>>>>>> REPLACE\nsuffix");
|
|
30
|
+
assert.strictEqual(blocks.length, 1);
|
|
31
|
+
});
|
|
32
|
+
test("rejects malformed block with missing REPLACE marker", () => {
|
|
33
|
+
assert.throws(() => parseSearchReplaceBlocks("<<<<<<< SEARCH\nfirst\n=======\nfirst new\n>>>>>>> REPLACE\n" +
|
|
34
|
+
"<<<<<<< SEARCH\nsecond\n=======\nsecond new\n>>>>>>> TYPO"), /malformed|Marker|marker/);
|
|
35
|
+
});
|
|
36
|
+
test("rejects block with missing ======= marker", () => {
|
|
37
|
+
assert.throws(() => parseSearchReplaceBlocks("<<<<<<< SEARCH\nfoo\n\nbar\n>>>>>>> REPLACE"), /malformed|Marker|marker/);
|
|
38
|
+
});
|
|
39
|
+
test("allows prose around valid blocks", () => {
|
|
40
|
+
const blocks = parseSearchReplaceBlocks("# Some notes\n\n<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE\n\nMore context");
|
|
41
|
+
assert.strictEqual(blocks.length, 1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("applySearchReplace", () => {
|
|
45
|
+
test("replaces single occurrence", () => {
|
|
46
|
+
const result = applySearchReplace("Status: In progress\nDone.", [
|
|
47
|
+
{ search: "Status: In progress", replace: "Status: Done" },
|
|
48
|
+
]);
|
|
49
|
+
assert.strictEqual(result.description, "Status: Done\nDone.");
|
|
50
|
+
assert.strictEqual(result.changes, 1);
|
|
51
|
+
assert.ok(result.summary.includes("Status: In progress"));
|
|
52
|
+
assert.ok(result.preview.includes("-Status: In progress"));
|
|
53
|
+
});
|
|
54
|
+
test("fails on no match", () => {
|
|
55
|
+
assert.throws(() => applySearchReplace("Some text.", [{ search: "Nonexistent", replace: "x" }]), /Search text not found/);
|
|
56
|
+
});
|
|
57
|
+
test("rejects empty SEARCH body", () => {
|
|
58
|
+
assert.throws(() => applySearchReplace("Some text.", [{ search: "", replace: "x" }], true), /Empty SEARCH/);
|
|
59
|
+
});
|
|
60
|
+
test("fails on duplicate without allowMultiple", () => {
|
|
61
|
+
assert.throws(() => applySearchReplace("x\ny\nx\n", [{ search: "x", replace: "z" }]), /matches 2 times/);
|
|
62
|
+
});
|
|
63
|
+
test("replaces all with allowMultiple", () => {
|
|
64
|
+
const result = applySearchReplace("x\ny\nx\n", [{ search: "x", replace: "z" }], true);
|
|
65
|
+
assert.strictEqual(result.changes, 2);
|
|
66
|
+
assert.strictEqual(result.description, "z\ny\nz\n");
|
|
67
|
+
});
|
|
68
|
+
test("fails on identical replacement", () => {
|
|
69
|
+
assert.throws(() => applySearchReplace("Keep this.", [{ search: "Keep this.", replace: "Keep this." }]), /did not change/);
|
|
70
|
+
});
|
|
71
|
+
test("preserves leading blank line in SEARCH block", () => {
|
|
72
|
+
const source = "\n\nStatus: In progress\n";
|
|
73
|
+
const result = applySearchReplace(source, [
|
|
74
|
+
{ search: "\n\nStatus: In progress", replace: "\n\nStatus: Done" },
|
|
75
|
+
]);
|
|
76
|
+
assert.strictEqual(result.description, "\n\nStatus: Done\n");
|
|
77
|
+
});
|
|
78
|
+
test("preserves leading blank line when patch starts with blank line", () => {
|
|
79
|
+
const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\n\nfoo\n=======\n\nbar\n>>>>>>> REPLACE");
|
|
80
|
+
assert.strictEqual(blocks.length, 1);
|
|
81
|
+
assert.strictEqual(blocks[0].search, "\nfoo");
|
|
82
|
+
assert.strictEqual(blocks[0].replace, "\nbar");
|
|
83
|
+
});
|
|
84
|
+
test("replacement with leading blank line works", () => {
|
|
85
|
+
const result = applySearchReplace("Header\n\nContent\n", [
|
|
86
|
+
{ search: "Content", replace: "\nNewContent" },
|
|
87
|
+
]);
|
|
88
|
+
assert.strictEqual(result.description, "Header\n\n\nNewContent\n");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("applyUnifiedDiff", () => {
|
|
92
|
+
test("applies simple diff", () => {
|
|
93
|
+
const source = "Line 1\nLine 2\nLine 3\n";
|
|
94
|
+
const patch = "--- old\n+++ new\n@@ -1,3 +1,3 @@\n Line 1\n-Line 2\n+Line 2 modified\n Line 3\n";
|
|
95
|
+
const result = applyUnifiedDiff(source, patch);
|
|
96
|
+
assert.ok(result.description.includes("Line 2 modified"));
|
|
97
|
+
});
|
|
98
|
+
test("fails on non-matching diff", () => {
|
|
99
|
+
const source = "AAA\nBBB\n";
|
|
100
|
+
const patch = "--- old\n+++ new\n@@ -1,2 +1,2 @@\n-XXX\n+YYY\n BBB\n";
|
|
101
|
+
assert.throws(() => applyUnifiedDiff(source, patch), /could not be applied/);
|
|
102
|
+
});
|
|
103
|
+
test("fails on malformed patch", () => {
|
|
104
|
+
assert.throws(() => applyUnifiedDiff("text", "not a patch"), /no valid hunks/i);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
// ---- Integration tests via MCP client ----
|
|
108
|
+
describe("update_issue_description_patch MCP tool", () => {
|
|
109
|
+
let mockGitLab;
|
|
110
|
+
let server;
|
|
111
|
+
let client;
|
|
112
|
+
const MOCK_PORT_BASE = 9600;
|
|
113
|
+
const MCP_PORT_BASE = 3600;
|
|
114
|
+
let portCounter = 0;
|
|
115
|
+
async function launchMcp(mockGitLabUrl, extraEnv = {}) {
|
|
116
|
+
const port = MCP_PORT_BASE + portCounter++ * 10;
|
|
117
|
+
return launchServer({
|
|
118
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
119
|
+
port,
|
|
120
|
+
timeout: 10000,
|
|
121
|
+
env: {
|
|
122
|
+
STREAMABLE_HTTP: "true",
|
|
123
|
+
REMOTE_AUTHORIZATION: "true",
|
|
124
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
125
|
+
GITLAB_ACCESS_TOKEN: MOCK_TOKEN,
|
|
126
|
+
...extraEnv,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function getClient(port) {
|
|
131
|
+
const client = new CustomHeaderClient({
|
|
132
|
+
headers: {
|
|
133
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
await client.connect(`http://${HOST}:${port}/mcp`);
|
|
137
|
+
return client;
|
|
138
|
+
}
|
|
139
|
+
before(async () => {
|
|
140
|
+
const mockPort = await findMockServerPort(MOCK_PORT_BASE);
|
|
141
|
+
mockGitLab = new MockGitLabServer({
|
|
142
|
+
port: mockPort,
|
|
143
|
+
validTokens: [MOCK_TOKEN],
|
|
144
|
+
});
|
|
145
|
+
await mockGitLab.start();
|
|
146
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
147
|
+
server = await launchMcp(mockGitLabUrl, { GITLAB_TOOLSETS: "issues" });
|
|
148
|
+
client = await getClient(server.port ?? 0);
|
|
149
|
+
});
|
|
150
|
+
after(async () => {
|
|
151
|
+
await client?.disconnect();
|
|
152
|
+
cleanupServers([server]);
|
|
153
|
+
await mockGitLab?.stop();
|
|
154
|
+
});
|
|
155
|
+
test("tool appears in tool list", async () => {
|
|
156
|
+
const result = await client.listTools();
|
|
157
|
+
const names = result.tools.map((t) => t.name);
|
|
158
|
+
assert.ok(names.includes("update_issue_description_patch"), "tool should be in list");
|
|
159
|
+
});
|
|
160
|
+
test("dry_run: search_replace returns preview without modifying", async () => {
|
|
161
|
+
// Get current description
|
|
162
|
+
const getResult = await client.callTool("get_issue", {
|
|
163
|
+
project_id: "test/project",
|
|
164
|
+
issue_iid: "1",
|
|
165
|
+
});
|
|
166
|
+
const issue = JSON.parse(getResult.content[0]?.text || "{}");
|
|
167
|
+
const originalDesc = issue.description;
|
|
168
|
+
// Dry-run
|
|
169
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
170
|
+
project_id: "test/project",
|
|
171
|
+
issue_iid: "1",
|
|
172
|
+
patch_type: "search_replace",
|
|
173
|
+
patch: `<<<<<<< SEARCH\n${originalDesc}\n=======\nShould NOT persist\n>>>>>>> REPLACE`,
|
|
174
|
+
dry_run: true,
|
|
175
|
+
});
|
|
176
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
177
|
+
assert.strictEqual(data.status, "preview");
|
|
178
|
+
assert.strictEqual(data.dry_run, true);
|
|
179
|
+
assert.strictEqual(data.changes, 1);
|
|
180
|
+
// Verify NOT updated
|
|
181
|
+
const getAgain = await client.callTool("get_issue", {
|
|
182
|
+
project_id: "test/project",
|
|
183
|
+
issue_iid: "1",
|
|
184
|
+
});
|
|
185
|
+
const issueAgain = JSON.parse(getAgain.content[0]?.text || "{}");
|
|
186
|
+
assert.strictEqual(issueAgain.description, originalDesc, "should be unchanged after dry_run");
|
|
187
|
+
});
|
|
188
|
+
test("search_replace: applies the patch", async () => {
|
|
189
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
190
|
+
project_id: "test/project",
|
|
191
|
+
issue_iid: "1",
|
|
192
|
+
patch_type: "search_replace",
|
|
193
|
+
patch: `<<<<<<< SEARCH\nDescription for issue 1\n=======\nPatched description\n>>>>>>> REPLACE`,
|
|
194
|
+
});
|
|
195
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
196
|
+
assert.strictEqual(data.status, "success");
|
|
197
|
+
assert.strictEqual(data.changes, 1);
|
|
198
|
+
// Verify persisted
|
|
199
|
+
const getResult = await client.callTool("get_issue", {
|
|
200
|
+
project_id: "test/project",
|
|
201
|
+
issue_iid: "1",
|
|
202
|
+
});
|
|
203
|
+
const issue = JSON.parse(getResult.content[0]?.text || "{}");
|
|
204
|
+
assert.strictEqual(issue.description, "Patched description");
|
|
205
|
+
});
|
|
206
|
+
test("create_note: note is attempted after update", async () => {
|
|
207
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
208
|
+
project_id: "test/project",
|
|
209
|
+
issue_iid: "1",
|
|
210
|
+
patch_type: "search_replace",
|
|
211
|
+
patch: `<<<<<<< SEARCH\nPatched description\n=======\nDescription with note\n>>>>>>> REPLACE`,
|
|
212
|
+
create_note: true,
|
|
213
|
+
});
|
|
214
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
215
|
+
assert.strictEqual(data.status, "success");
|
|
216
|
+
assert.ok(data.note !== undefined, "note result should be present");
|
|
217
|
+
});
|
|
218
|
+
test("search_replace: fails on no match", async () => {
|
|
219
|
+
try {
|
|
220
|
+
await client.callTool("update_issue_description_patch", {
|
|
221
|
+
project_id: "test/project",
|
|
222
|
+
issue_iid: "1",
|
|
223
|
+
patch_type: "search_replace",
|
|
224
|
+
patch: `<<<<<<< SEARCH\nNonExistentText_{UNIQUE}_\n=======\nShould fail\n>>>>>>> REPLACE`,
|
|
225
|
+
});
|
|
226
|
+
assert.fail("Should have thrown");
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
const msg = err.message ?? String(err);
|
|
230
|
+
assert.ok(msg.includes("not found"), `Error should mention 'not found': ${msg}`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
test("unified_diff: applies the patch", async () => {
|
|
234
|
+
// Set known state
|
|
235
|
+
await client.callTool("update_issue_description_patch", {
|
|
236
|
+
project_id: "test/project",
|
|
237
|
+
issue_iid: "1",
|
|
238
|
+
patch_type: "search_replace",
|
|
239
|
+
patch: `<<<<<<< SEARCH\nDescription with note\n=======\nLine 1\nLine 2\nLine 3\n>>>>>>> REPLACE`,
|
|
240
|
+
});
|
|
241
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
242
|
+
project_id: "test/project",
|
|
243
|
+
issue_iid: "1",
|
|
244
|
+
patch_type: "unified_diff",
|
|
245
|
+
patch: "--- old\n+++ new\n@@ -1,3 +1,3 @@\n Line 1\n-Line 2\n+Line 2 changed\n Line 3\n",
|
|
246
|
+
});
|
|
247
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
248
|
+
assert.strictEqual(data.status, "success");
|
|
249
|
+
const getResult = await client.callTool("get_issue", {
|
|
250
|
+
project_id: "test/project",
|
|
251
|
+
issue_iid: "1",
|
|
252
|
+
});
|
|
253
|
+
const issue = JSON.parse(getResult.content[0]?.text || "{}");
|
|
254
|
+
assert.ok(issue.description.includes("Line 2 changed"));
|
|
255
|
+
});
|
|
256
|
+
});
|