@zereight/mcp-gitlab 2.1.11 → 2.1.13
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 +152 -150
- package/build/index.js +205 -9
- package/build/schemas.js +124 -0
- package/build/test/test-get-file-blame.js +145 -0
- package/build/test/test-geteffectiveprojectid.js +230 -6
- package/build/test/test-issue-description-patch.js +256 -0
- package/build/test/test-token-optimizations.js +1 -1
- package/build/test/test-toolset-filtering.js +7 -3
- package/build/test/utils/mock-gitlab-server.js +46 -0
- package/build/tools/registry.js +52 -3
- package/build/utils/patch-helper.js +145 -0
- package/package.json +3 -2
package/build/index.js
CHANGED
|
@@ -21,17 +21,18 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
|
|
|
21
21
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
22
22
|
import { normalizeGitLabApiUrl } from "./utils/url.js";
|
|
23
23
|
import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
|
|
24
|
+
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
|
|
24
25
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
25
26
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
26
27
|
import { allTools, readOnlyTools, destructiveTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
|
|
27
28
|
import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
|
|
28
|
-
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
|
|
29
|
+
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
|
|
29
30
|
// Discussion Schemas
|
|
30
31
|
GitLabDiscussionNoteSchema, // Added
|
|
31
32
|
GitLabDiscussionSchema,
|
|
32
33
|
// Draft Notes Schemas
|
|
33
|
-
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
34
|
-
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
|
|
34
|
+
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
35
|
+
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
|
|
35
36
|
import { randomUUID } from "node:crypto";
|
|
36
37
|
import { pino } from "pino";
|
|
37
38
|
const logger = pino({
|
|
@@ -936,6 +937,14 @@ function getEffectiveProjectId(projectId) {
|
|
|
936
937
|
}
|
|
937
938
|
throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
|
|
938
939
|
}
|
|
940
|
+
function rejectIfProjectScopedDeployment(toolName) {
|
|
941
|
+
if (GITLAB_PROJECT_ID) {
|
|
942
|
+
throw new Error(`${toolName} is not allowed when GITLAB_PROJECT_ID is set (server is locked to a single project)`);
|
|
943
|
+
}
|
|
944
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
|
|
945
|
+
throw new Error(`${toolName} is not allowed when GITLAB_ALLOWED_PROJECT_IDS is set (server access is restricted to configured projects)`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
939
948
|
/**
|
|
940
949
|
* Create a fork of a GitLab project
|
|
941
950
|
* 프로젝트 포크 생성 (Create a project fork)
|
|
@@ -5415,6 +5424,33 @@ async function getCommitDiff(projectId, sha, full_diff) {
|
|
|
5415
5424
|
}
|
|
5416
5425
|
return allDiffs;
|
|
5417
5426
|
}
|
|
5427
|
+
/**
|
|
5428
|
+
* Get blame for a file at a specific ref.
|
|
5429
|
+
*
|
|
5430
|
+
* Wraps GitLab REST endpoint
|
|
5431
|
+
* GET /projects/:id/repository/files/:file_path/blame?ref=
|
|
5432
|
+
* Returns an array of entries; each entry has `lines` (the source lines covered)
|
|
5433
|
+
* and `commit` (the commit that last changed those lines: id, author, message, ...).
|
|
5434
|
+
*
|
|
5435
|
+
* @param {string} projectId - Project ID or URL-encoded path
|
|
5436
|
+
* @param {Omit<GetFileBlameOptions,"project_id">} options - file_path, ref, optional range_start/range_end
|
|
5437
|
+
* @returns {Promise<GitLabBlameEntry[]>} Blame entries in source order.
|
|
5438
|
+
*/
|
|
5439
|
+
async function getFileBlame(projectId, options) {
|
|
5440
|
+
projectId = decodeURIComponent(projectId);
|
|
5441
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodeURIComponent(options.file_path)}/blame`);
|
|
5442
|
+
url.searchParams.append("ref", options.ref);
|
|
5443
|
+
if (options.range_start !== undefined && options.range_end !== undefined) {
|
|
5444
|
+
url.searchParams.append("range[start]", options.range_start.toString());
|
|
5445
|
+
url.searchParams.append("range[end]", options.range_end.toString());
|
|
5446
|
+
}
|
|
5447
|
+
const response = await fetch(url.toString(), {
|
|
5448
|
+
...getFetchConfig(),
|
|
5449
|
+
});
|
|
5450
|
+
await handleGitLabError(response);
|
|
5451
|
+
const data = await response.json();
|
|
5452
|
+
return z.array(GitLabBlameEntrySchema).parse(data);
|
|
5453
|
+
}
|
|
5418
5454
|
/**
|
|
5419
5455
|
* List statuses for a commit.
|
|
5420
5456
|
*
|
|
@@ -5958,6 +5994,10 @@ async function handleToolCall(params) {
|
|
|
5958
5994
|
delete args.work_item_iid;
|
|
5959
5995
|
}
|
|
5960
5996
|
}
|
|
5997
|
+
// Centralized read-only guard: reject write tools even if client bypasses list_tools filtering
|
|
5998
|
+
if (GITLAB_READ_ONLY_MODE && !readOnlyTools.has(params.name)) {
|
|
5999
|
+
throw new Error(`${params.name} is not allowed in read-only mode`);
|
|
6000
|
+
}
|
|
5961
6001
|
logger.info({ tool: params.name, event: "tool_call_start" }, `tool_call_start: ${params.name}`);
|
|
5962
6002
|
switch (params.name) {
|
|
5963
6003
|
case "execute_graphql": {
|
|
@@ -6008,9 +6048,7 @@ async function handleToolCall(params) {
|
|
|
6008
6048
|
}
|
|
6009
6049
|
}
|
|
6010
6050
|
case "fork_repository": {
|
|
6011
|
-
|
|
6012
|
-
throw new Error("Direct project ID is set. So fork_repository is not allowed");
|
|
6013
|
-
}
|
|
6051
|
+
rejectIfProjectScopedDeployment("fork_repository");
|
|
6014
6052
|
const forkArgs = ForkRepositorySchema.parse(params.arguments);
|
|
6015
6053
|
try {
|
|
6016
6054
|
const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace);
|
|
@@ -6109,15 +6147,40 @@ async function handleToolCall(params) {
|
|
|
6109
6147
|
};
|
|
6110
6148
|
}
|
|
6111
6149
|
case "create_repository": {
|
|
6112
|
-
|
|
6113
|
-
throw new Error("Direct project ID is set. So fork_repository is not allowed");
|
|
6114
|
-
}
|
|
6150
|
+
rejectIfProjectScopedDeployment("create_repository");
|
|
6115
6151
|
const args = CreateRepositorySchema.parse(params.arguments);
|
|
6116
6152
|
const repository = await createRepository(args);
|
|
6117
6153
|
return {
|
|
6118
6154
|
content: [{ type: "text", text: JSON.stringify(repository, null, 2) }],
|
|
6119
6155
|
};
|
|
6120
6156
|
}
|
|
6157
|
+
case "create_group": {
|
|
6158
|
+
rejectIfProjectScopedDeployment("create_group");
|
|
6159
|
+
const args = CreateGroupSchema.parse(params.arguments);
|
|
6160
|
+
const url = new URL(`${getEffectiveApiUrl()}/groups`);
|
|
6161
|
+
const body = {
|
|
6162
|
+
name: args.name,
|
|
6163
|
+
path: args.path,
|
|
6164
|
+
};
|
|
6165
|
+
if (args.description)
|
|
6166
|
+
body.description = args.description;
|
|
6167
|
+
if (args.visibility)
|
|
6168
|
+
body.visibility = args.visibility;
|
|
6169
|
+
if (args.parent_id)
|
|
6170
|
+
body.parent_id = args.parent_id;
|
|
6171
|
+
const response = await fetch(url.toString(), {
|
|
6172
|
+
...getFetchConfig(),
|
|
6173
|
+
method: "POST",
|
|
6174
|
+
headers: { ...getFetchConfig().headers, "Content-Type": "application/json" },
|
|
6175
|
+
body: JSON.stringify(body),
|
|
6176
|
+
});
|
|
6177
|
+
await handleGitLabError(response);
|
|
6178
|
+
const data = await response.json();
|
|
6179
|
+
const group = GitLabGroupSchema.parse(data);
|
|
6180
|
+
return {
|
|
6181
|
+
content: [{ type: "text", text: JSON.stringify(group, null, 2) }],
|
|
6182
|
+
};
|
|
6183
|
+
}
|
|
6121
6184
|
case "get_file_contents": {
|
|
6122
6185
|
const args = GetFileContentsSchema.parse(params.arguments);
|
|
6123
6186
|
const contents = await getFileContents(args.project_id, args.file_path, args.ref);
|
|
@@ -6683,6 +6746,81 @@ async function handleToolCall(params) {
|
|
|
6683
6746
|
content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
|
|
6684
6747
|
};
|
|
6685
6748
|
}
|
|
6749
|
+
case "update_issue_description_patch": {
|
|
6750
|
+
const args = UpdateIssueDescriptionPatchSchema.parse(params.arguments);
|
|
6751
|
+
const { project_id, issue_iid, patch_type, patch, dry_run, create_note, allow_multiple } = args;
|
|
6752
|
+
// Fetch current issue description
|
|
6753
|
+
const currentIssue = await getIssue(project_id, issue_iid);
|
|
6754
|
+
const currentDescription = currentIssue.description ?? "";
|
|
6755
|
+
// Apply the patch
|
|
6756
|
+
let result;
|
|
6757
|
+
if (patch_type === "search_replace") {
|
|
6758
|
+
const blocks = parseSearchReplaceBlocks(patch);
|
|
6759
|
+
if (blocks.length === 0) {
|
|
6760
|
+
throw new Error("No valid search/replace blocks found. Expected format: <<<<<<< SEARCH\\ntext\\n=======\\nnew text\\n>>>>>>> REPLACE");
|
|
6761
|
+
}
|
|
6762
|
+
result = applySearchReplace(currentDescription, blocks, allow_multiple);
|
|
6763
|
+
}
|
|
6764
|
+
else {
|
|
6765
|
+
// unified_diff
|
|
6766
|
+
result = applyUnifiedDiff(currentDescription, patch);
|
|
6767
|
+
}
|
|
6768
|
+
// Dry-run: return preview without updating
|
|
6769
|
+
if (dry_run) {
|
|
6770
|
+
return {
|
|
6771
|
+
content: [
|
|
6772
|
+
{
|
|
6773
|
+
type: "text",
|
|
6774
|
+
text: JSON.stringify({
|
|
6775
|
+
status: "preview",
|
|
6776
|
+
dry_run: true,
|
|
6777
|
+
changes: result.changes,
|
|
6778
|
+
summary: result.summary,
|
|
6779
|
+
preview: result.preview,
|
|
6780
|
+
}, null, 2),
|
|
6781
|
+
},
|
|
6782
|
+
],
|
|
6783
|
+
};
|
|
6784
|
+
}
|
|
6785
|
+
// Apply the update
|
|
6786
|
+
const updatedIssue = await updateIssue(project_id, issue_iid, {
|
|
6787
|
+
description: result.description,
|
|
6788
|
+
});
|
|
6789
|
+
// Optionally create a note summarizing the change
|
|
6790
|
+
let noteResult = null;
|
|
6791
|
+
if (create_note) {
|
|
6792
|
+
try {
|
|
6793
|
+
const noteBody = `Updated issue description using patch-based tool.\n\n${result.summary}`;
|
|
6794
|
+
await createIssueNote(project_id, issue_iid, undefined, noteBody);
|
|
6795
|
+
noteResult = { status: "created" };
|
|
6796
|
+
}
|
|
6797
|
+
catch (noteError) {
|
|
6798
|
+
noteResult = {
|
|
6799
|
+
status: "failed",
|
|
6800
|
+
message: `Note creation failed: ${noteError.message ?? noteError}`,
|
|
6801
|
+
};
|
|
6802
|
+
}
|
|
6803
|
+
}
|
|
6804
|
+
return {
|
|
6805
|
+
content: [
|
|
6806
|
+
{
|
|
6807
|
+
type: "text",
|
|
6808
|
+
text: JSON.stringify({
|
|
6809
|
+
status: "success",
|
|
6810
|
+
changes: result.changes,
|
|
6811
|
+
summary: result.summary,
|
|
6812
|
+
note: noteResult,
|
|
6813
|
+
issue: {
|
|
6814
|
+
iid: updatedIssue.iid,
|
|
6815
|
+
title: updatedIssue.title,
|
|
6816
|
+
web_url: updatedIssue.web_url,
|
|
6817
|
+
updated_at: updatedIssue.updated_at,
|
|
6818
|
+
},
|
|
6819
|
+
}, null, 2),
|
|
6820
|
+
},
|
|
6821
|
+
],
|
|
6822
|
+
};
|
|
6823
|
+
}
|
|
6686
6824
|
case "delete_issue": {
|
|
6687
6825
|
const args = DeleteIssueSchema.parse(params.arguments);
|
|
6688
6826
|
await deleteIssue(args.project_id, args.issue_iid);
|
|
@@ -7390,6 +7528,14 @@ async function handleToolCall(params) {
|
|
|
7390
7528
|
content: [{ type: "text", text: JSON.stringify(diff, null, 2) }],
|
|
7391
7529
|
};
|
|
7392
7530
|
}
|
|
7531
|
+
case "get_file_blame": {
|
|
7532
|
+
const args = GetFileBlameSchema.parse(params.arguments);
|
|
7533
|
+
const { project_id, ...options } = args;
|
|
7534
|
+
const blame = await getFileBlame(project_id, options);
|
|
7535
|
+
return {
|
|
7536
|
+
content: [{ type: "text", text: JSON.stringify(blame, null, 2) }],
|
|
7537
|
+
};
|
|
7538
|
+
}
|
|
7393
7539
|
case "list_commit_statuses": {
|
|
7394
7540
|
const args = ListCommitStatusesSchema.parse(params.arguments);
|
|
7395
7541
|
const { project_id, sha, ...options } = args;
|
|
@@ -7612,6 +7758,56 @@ async function handleToolCall(params) {
|
|
|
7612
7758
|
content: [{ type: "text", text: JSON.stringify({ status: authenticated ? "ok" : "error", authenticated, gitlab_url: getEffectiveApiUrl() }) }],
|
|
7613
7759
|
};
|
|
7614
7760
|
}
|
|
7761
|
+
case "get_branch": {
|
|
7762
|
+
const args = GetBranchSchema.parse(params.arguments);
|
|
7763
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
7764
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches/${encodeURIComponent(args.branch_name)}`);
|
|
7765
|
+
const response = await fetch(url.toString(), {
|
|
7766
|
+
...getFetchConfig(),
|
|
7767
|
+
});
|
|
7768
|
+
await handleGitLabError(response);
|
|
7769
|
+
const data = await response.json();
|
|
7770
|
+
const branch = GitLabBranchSchema.parse(data);
|
|
7771
|
+
return {
|
|
7772
|
+
content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
|
|
7773
|
+
};
|
|
7774
|
+
}
|
|
7775
|
+
case "list_branches": {
|
|
7776
|
+
const args = ListBranchesSchema.parse(params.arguments);
|
|
7777
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
7778
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches`);
|
|
7779
|
+
if (args.search) {
|
|
7780
|
+
url.searchParams.append("search", args.search);
|
|
7781
|
+
}
|
|
7782
|
+
if (args.page) {
|
|
7783
|
+
url.searchParams.append("page", args.page.toString());
|
|
7784
|
+
}
|
|
7785
|
+
if (args.per_page) {
|
|
7786
|
+
url.searchParams.append("per_page", args.per_page.toString());
|
|
7787
|
+
}
|
|
7788
|
+
const response = await fetch(url.toString(), {
|
|
7789
|
+
...getFetchConfig(),
|
|
7790
|
+
});
|
|
7791
|
+
await handleGitLabError(response);
|
|
7792
|
+
const data = await response.json();
|
|
7793
|
+
const branches = z.array(GitLabBranchSchema).parse(data);
|
|
7794
|
+
return {
|
|
7795
|
+
content: [{ type: "text", text: JSON.stringify(branches, null, 2) }],
|
|
7796
|
+
};
|
|
7797
|
+
}
|
|
7798
|
+
case "delete_branch": {
|
|
7799
|
+
const args = DeleteBranchSchema.parse(params.arguments);
|
|
7800
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
7801
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/branches/${encodeURIComponent(args.branch_name)}`);
|
|
7802
|
+
const response = await fetch(url.toString(), {
|
|
7803
|
+
...getFetchConfig(),
|
|
7804
|
+
method: "DELETE",
|
|
7805
|
+
});
|
|
7806
|
+
await handleGitLabError(response);
|
|
7807
|
+
return {
|
|
7808
|
+
content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }, null, 2) }],
|
|
7809
|
+
};
|
|
7810
|
+
}
|
|
7615
7811
|
default:
|
|
7616
7812
|
throw new Error(`Unknown tool: ${params.name}`);
|
|
7617
7813
|
}
|
package/build/schemas.js
CHANGED
|
@@ -595,6 +595,39 @@ export const GitLabCurrentUserSchema = z.object({
|
|
|
595
595
|
extern_uid: z.string(),
|
|
596
596
|
})).optional(),
|
|
597
597
|
}).passthrough();
|
|
598
|
+
// Group related schemas
|
|
599
|
+
export const CreateGroupSchema = z.object({
|
|
600
|
+
name: z.string().describe("The name of the group"),
|
|
601
|
+
path: z.string().describe("The path of the group"),
|
|
602
|
+
description: z.string().optional().describe("The group's description"),
|
|
603
|
+
visibility: z.enum(["private", "internal", "public"]).optional().describe("The group's visibility level"),
|
|
604
|
+
parent_id: z.coerce.number().optional().describe("The parent group ID for creating a subgroup"),
|
|
605
|
+
});
|
|
606
|
+
export const GitLabGroupSchema = z.object({
|
|
607
|
+
id: z.coerce.string(),
|
|
608
|
+
name: z.string(),
|
|
609
|
+
path: z.string(),
|
|
610
|
+
description: z.string().nullable(),
|
|
611
|
+
visibility: z.string().optional(),
|
|
612
|
+
share_with_group_lock: z.boolean().optional(),
|
|
613
|
+
require_two_factor_authentication: z.boolean().optional(),
|
|
614
|
+
two_factor_grace_period: z.number().optional(),
|
|
615
|
+
project_creation_level: z.string().optional(),
|
|
616
|
+
auto_devops_enabled: z.boolean().nullable().optional(),
|
|
617
|
+
subgroup_creation_level: z.string().optional(),
|
|
618
|
+
emails_disabled: z.boolean().nullable().optional(),
|
|
619
|
+
mentions_disabled: z.boolean().nullable().optional(),
|
|
620
|
+
lfs_enabled: z.boolean().nullable().optional(),
|
|
621
|
+
avatar_url: z.string().nullable().optional(),
|
|
622
|
+
web_url: z.string(),
|
|
623
|
+
request_access_enabled: z.boolean().nullable().optional(),
|
|
624
|
+
full_name: z.string(),
|
|
625
|
+
full_path: z.string(),
|
|
626
|
+
file_template_project_id: z.number().nullable().optional(),
|
|
627
|
+
parent_id: z.coerce.string().nullable().optional(),
|
|
628
|
+
created_at: z.string().optional(),
|
|
629
|
+
statistics: z.any().optional(),
|
|
630
|
+
});
|
|
598
631
|
// Namespace related schemas
|
|
599
632
|
// Base schema for project-related operations
|
|
600
633
|
const ProjectParamsSchema = z.object({
|
|
@@ -1472,6 +1505,37 @@ export const CreateBranchSchema = ProjectParamsSchema.extend({
|
|
|
1472
1505
|
branch: z.string().describe("Name for the new branch"),
|
|
1473
1506
|
ref: z.string().optional().describe("Source branch/commit for new branch"),
|
|
1474
1507
|
});
|
|
1508
|
+
export const GetBranchSchema = ProjectParamsSchema.extend({
|
|
1509
|
+
branch_name: z.string().describe("Name of the branch"),
|
|
1510
|
+
});
|
|
1511
|
+
export const ListBranchesSchema = ProjectParamsSchema.extend({
|
|
1512
|
+
search: z.string().optional().describe("Search term to filter branches by name"),
|
|
1513
|
+
}).merge(PaginationOptionsSchema);
|
|
1514
|
+
export const DeleteBranchSchema = ProjectParamsSchema.extend({
|
|
1515
|
+
branch_name: z.string().describe("Name of the branch to delete"),
|
|
1516
|
+
});
|
|
1517
|
+
export const GitLabBranchSchema = z.object({
|
|
1518
|
+
name: z.string(),
|
|
1519
|
+
commit: z.object({
|
|
1520
|
+
id: z.string(),
|
|
1521
|
+
short_id: z.string(),
|
|
1522
|
+
title: z.string(),
|
|
1523
|
+
author_name: z.string(),
|
|
1524
|
+
author_email: z.string(),
|
|
1525
|
+
authored_date: z.string(),
|
|
1526
|
+
committer_name: z.string(),
|
|
1527
|
+
committer_email: z.string(),
|
|
1528
|
+
committed_date: z.string(),
|
|
1529
|
+
web_url: z.string(),
|
|
1530
|
+
}),
|
|
1531
|
+
merged: z.boolean(),
|
|
1532
|
+
protected: z.boolean(),
|
|
1533
|
+
developers_can_push: z.boolean(),
|
|
1534
|
+
developers_can_merge: z.boolean(),
|
|
1535
|
+
can_push: z.boolean(),
|
|
1536
|
+
default: z.boolean(),
|
|
1537
|
+
web_url: z.string().optional(),
|
|
1538
|
+
});
|
|
1475
1539
|
export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
|
|
1476
1540
|
from: z.string().describe("The base branch or commit SHA to compare from"),
|
|
1477
1541
|
to: z.string().describe("The target branch or commit SHA to compare to"),
|
|
@@ -1839,6 +1903,22 @@ export const DeleteIssueSchema = z.object({
|
|
|
1839
1903
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
1840
1904
|
issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
|
|
1841
1905
|
});
|
|
1906
|
+
export const UpdateIssueDescriptionPatchSchema = z.object({
|
|
1907
|
+
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
1908
|
+
issue_iid: z.coerce.string().describe("The internal ID of the project issue"),
|
|
1909
|
+
patch_type: z.enum(["search_replace", "unified_diff"]).describe("Type of patch format to apply"),
|
|
1910
|
+
patch: z
|
|
1911
|
+
.string()
|
|
1912
|
+
.min(1)
|
|
1913
|
+
.max(50000)
|
|
1914
|
+
.describe("The patch content to apply to the issue description"),
|
|
1915
|
+
dry_run: z.coerce.boolean().optional().describe("If true, preview changes without updating the issue"),
|
|
1916
|
+
create_note: z.coerce.boolean().optional().describe("If true, add a note summarizing the change after update"),
|
|
1917
|
+
allow_multiple: z
|
|
1918
|
+
.coerce.boolean()
|
|
1919
|
+
.optional()
|
|
1920
|
+
.describe("For search_replace: allow multiple matches to all be replaced (default: false — fail on duplicate)"),
|
|
1921
|
+
});
|
|
1842
1922
|
// Issue links related schemas
|
|
1843
1923
|
export const GitLabIssueLinkSchema = z.object({
|
|
1844
1924
|
source_issue: GitLabIssueSchema,
|
|
@@ -2357,6 +2437,50 @@ export const GetCommitDiffSchema = z.object({
|
|
|
2357
2437
|
.optional()
|
|
2358
2438
|
.describe("Whether to return the full diff or only first page (default: false)"),
|
|
2359
2439
|
});
|
|
2440
|
+
export const GetFileBlameSchema = z
|
|
2441
|
+
.object({
|
|
2442
|
+
project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
|
|
2443
|
+
file_path: z.string().describe("The full path of the file to blame, relative to repo root"),
|
|
2444
|
+
ref: z
|
|
2445
|
+
.string()
|
|
2446
|
+
.describe("The name of branch, tag or commit (required by GitLab blame API)"),
|
|
2447
|
+
range_start: z
|
|
2448
|
+
.coerce.number()
|
|
2449
|
+
.int()
|
|
2450
|
+
.optional()
|
|
2451
|
+
.describe("First line of the blame range (inclusive, 1-based). Both range[start] and range[end] must be set together."),
|
|
2452
|
+
range_end: z
|
|
2453
|
+
.coerce.number()
|
|
2454
|
+
.int()
|
|
2455
|
+
.optional()
|
|
2456
|
+
.describe("Last line of the blame range (inclusive, 1-based). Both range[start] and range[end] must be set together."),
|
|
2457
|
+
})
|
|
2458
|
+
.refine((v) => (v.range_start === undefined) === (v.range_end === undefined), {
|
|
2459
|
+
message: "range_start and range_end must be provided together (both or neither). Passing only one silently returned full-file blame on GitLab side.",
|
|
2460
|
+
path: ["range_end"],
|
|
2461
|
+
})
|
|
2462
|
+
.refine((v) => v.range_start === undefined ||
|
|
2463
|
+
v.range_end === undefined ||
|
|
2464
|
+
v.range_start <= v.range_end, {
|
|
2465
|
+
message: "range_start must be less than or equal to range_end.",
|
|
2466
|
+
path: ["range_start"],
|
|
2467
|
+
});
|
|
2468
|
+
export const GitLabBlameEntrySchema = z.object({
|
|
2469
|
+
lines: z.array(z.string()).describe("Source lines covered by this blame range"),
|
|
2470
|
+
commit: z
|
|
2471
|
+
.object({
|
|
2472
|
+
id: z.string(),
|
|
2473
|
+
parent_ids: z.array(z.string()).optional(),
|
|
2474
|
+
message: z.string().optional(),
|
|
2475
|
+
authored_date: z.string().optional(),
|
|
2476
|
+
author_name: z.string().optional(),
|
|
2477
|
+
author_email: z.string().optional(),
|
|
2478
|
+
committed_date: z.string().optional(),
|
|
2479
|
+
committer_name: z.string().optional(),
|
|
2480
|
+
committer_email: z.string().optional(),
|
|
2481
|
+
})
|
|
2482
|
+
.passthrough(),
|
|
2483
|
+
});
|
|
2360
2484
|
export const ListCommitStatusesSchema = z
|
|
2361
2485
|
.object({
|
|
2362
2486
|
project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-12345";
|
|
6
|
+
const TEST_PROJECT_ID = "123";
|
|
7
|
+
const MOCK_BLAME = [
|
|
8
|
+
{
|
|
9
|
+
lines: ["line one", ""],
|
|
10
|
+
commit: {
|
|
11
|
+
id: "1111111111111111111111111111111111111111",
|
|
12
|
+
message: "feat: initial commit",
|
|
13
|
+
authored_date: "2024-01-01T00:00:00.000Z",
|
|
14
|
+
author_name: "Alice",
|
|
15
|
+
author_email: "alice@example.com",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
lines: ["line three"],
|
|
20
|
+
commit: {
|
|
21
|
+
id: "2222222222222222222222222222222222222222",
|
|
22
|
+
message: "feat: add second change",
|
|
23
|
+
authored_date: "2024-02-02T00:00:00.000Z",
|
|
24
|
+
author_name: "Bob",
|
|
25
|
+
author_email: "bob@example.com",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
async function callGetFileBlame(args, env) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
...env,
|
|
36
|
+
GITLAB_READ_ONLY_MODE: "true",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
let output = "";
|
|
40
|
+
let errorOutput = "";
|
|
41
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
42
|
+
proc.stderr?.on("data", (d) => (errorOutput += d));
|
|
43
|
+
proc.on("close", (code) => {
|
|
44
|
+
if (code !== 0)
|
|
45
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
46
|
+
const line = output.split("\n").find((l) => l.startsWith("{"));
|
|
47
|
+
if (!line)
|
|
48
|
+
return reject(new Error("No JSON output found"));
|
|
49
|
+
try {
|
|
50
|
+
const response = JSON.parse(line);
|
|
51
|
+
if (response.error)
|
|
52
|
+
return reject(response.error);
|
|
53
|
+
const content = response.result?.content?.[0]?.text;
|
|
54
|
+
if (content)
|
|
55
|
+
return resolve(JSON.parse(content));
|
|
56
|
+
resolve(response.result);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
reject(e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
proc.stdin?.end(JSON.stringify({
|
|
63
|
+
jsonrpc: "2.0",
|
|
64
|
+
id: 1,
|
|
65
|
+
method: "tools/call",
|
|
66
|
+
params: { name: "get_file_blame", arguments: args },
|
|
67
|
+
}) + "\n");
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
describe("get_file_blame", () => {
|
|
71
|
+
let mockGitLab;
|
|
72
|
+
let mockGitLabUrl;
|
|
73
|
+
let lastQuery = {};
|
|
74
|
+
before(async () => {
|
|
75
|
+
const mockPort = await findMockServerPort(9000);
|
|
76
|
+
mockGitLab = new MockGitLabServer({
|
|
77
|
+
port: mockPort,
|
|
78
|
+
validTokens: [MOCK_TOKEN],
|
|
79
|
+
});
|
|
80
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/src%2Fexample.txt/blame`, (req, res) => {
|
|
81
|
+
lastQuery = req.query;
|
|
82
|
+
res.json(MOCK_BLAME);
|
|
83
|
+
});
|
|
84
|
+
await mockGitLab.start();
|
|
85
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
86
|
+
});
|
|
87
|
+
after(async () => {
|
|
88
|
+
await mockGitLab.stop();
|
|
89
|
+
});
|
|
90
|
+
test("returns blame entries for a file at ref", async () => {
|
|
91
|
+
const blame = await callGetFileBlame({
|
|
92
|
+
project_id: TEST_PROJECT_ID,
|
|
93
|
+
file_path: "src/example.txt",
|
|
94
|
+
ref: "main",
|
|
95
|
+
}, {
|
|
96
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
97
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
98
|
+
});
|
|
99
|
+
assert.ok(Array.isArray(blame), "Response should be an array");
|
|
100
|
+
assert.strictEqual(blame.length, 2, "Two blame entries expected");
|
|
101
|
+
assert.strictEqual(blame[1].commit.id, "2222222222222222222222222222222222222222", "second entry commit id matches");
|
|
102
|
+
assert.deepStrictEqual(blame[1].lines, ["line three"]);
|
|
103
|
+
assert.strictEqual(lastQuery.ref, "main", "ref propagated to GitLab API");
|
|
104
|
+
assert.ok(!("range[start]" in lastQuery) && !("range[end]" in lastQuery), "no range params when omitted");
|
|
105
|
+
});
|
|
106
|
+
test("passes range[start]/range[end] when both set", async () => {
|
|
107
|
+
await callGetFileBlame({
|
|
108
|
+
project_id: TEST_PROJECT_ID,
|
|
109
|
+
file_path: "src/example.txt",
|
|
110
|
+
ref: "main",
|
|
111
|
+
range_start: 10,
|
|
112
|
+
range_end: 20,
|
|
113
|
+
}, {
|
|
114
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
115
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
116
|
+
});
|
|
117
|
+
assert.strictEqual(lastQuery["range[start]"], "10");
|
|
118
|
+
assert.strictEqual(lastQuery["range[end]"], "20");
|
|
119
|
+
});
|
|
120
|
+
test("rejects partial range (range_start only) at schema layer", async () => {
|
|
121
|
+
await assert.rejects(() => callGetFileBlame({
|
|
122
|
+
project_id: TEST_PROJECT_ID,
|
|
123
|
+
file_path: "src/example.txt",
|
|
124
|
+
ref: "main",
|
|
125
|
+
range_start: 10,
|
|
126
|
+
}, {
|
|
127
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
128
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
129
|
+
}), (e) => typeof e?.message === "string" &&
|
|
130
|
+
e.message.includes("range_start and range_end must be provided together"));
|
|
131
|
+
});
|
|
132
|
+
test("rejects inverted range (start > end) at schema layer", async () => {
|
|
133
|
+
await assert.rejects(() => callGetFileBlame({
|
|
134
|
+
project_id: TEST_PROJECT_ID,
|
|
135
|
+
file_path: "src/example.txt",
|
|
136
|
+
ref: "main",
|
|
137
|
+
range_start: 20,
|
|
138
|
+
range_end: 10,
|
|
139
|
+
}, {
|
|
140
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
141
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
142
|
+
}), (e) => typeof e?.message === "string" &&
|
|
143
|
+
e.message.includes("range_start must be less than or equal to range_end"));
|
|
144
|
+
});
|
|
145
|
+
});
|