diff --git a/index.ts b/index.ts index accc01a..da09ff5 100644 --- a/index.ts +++ b/index.ts @@ -100,6 +100,7 @@ import { // Discussion Schemas GitLabDiscussionNoteSchema, // Added GitLabDiscussionSchema, + PaginatedDiscussionsResponseSchema, UpdateMergeRequestNoteSchema, // Added CreateMergeRequestNoteSchema, // Added ListMergeRequestDiscussionsSchema, @@ -142,8 +143,10 @@ import { type PromoteProjectMilestoneOptions, type GetMilestoneBurndownEventsOptions, // Discussion Types - type GitLabDiscussionNote, // Added + type GitLabDiscussionNote, type GitLabDiscussion, + type PaginatedDiscussionsResponse, + type PaginationOptions, type MergeRequestThreadPosition, type GetWikiPageOptions, type CreateWikiPageOptions, @@ -1209,55 +1212,26 @@ async function createMergeRequest( } /** - * List merge request discussion items - * 병합 요청 토론 목록 조회 + * Shared helper function for listing discussions + * 토론 목록 조회를 위한 공유 헬퍼 함수 * * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @returns {Promise} List of discussions + * @param {"issues" | "merge_requests"} resourceType - The type of resource (issues or merge_requests) + * @param {number} resourceIid - The IID of the issue or merge request + * @param {PaginationOptions} options - Pagination and sorting options + * @returns {Promise} Paginated list of discussions */ -async function listMergeRequestDiscussions( +async function listDiscussions( projectId: string, - mergeRequestIid: number -): Promise { + resourceType: "issues" | "merge_requests", + resourceIid: number, + options: PaginationOptions = {} +): Promise { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL( `${GITLAB_API_URL}/projects/${encodeURIComponent( projectId - )}/merge_requests/${mergeRequestIid}/discussions` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - // Ensure the response is parsed as an array of discussions - return z.array(GitLabDiscussionSchema).parse(data); -} - -/** - * List discussions for an issue - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {Object} options - Pagination and sorting options - * @returns {Promise} List of issue discussions - */ -async function listIssueDiscussions( - projectId: string, - issueIid: number, - options: { - page?: number; - per_page?: number; - sort?: "asc" | "desc"; - order_by?: "created_at" | "updated_at"; - } = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/discussions` + )}/${resourceType}/${resourceIid}/discussions` ); // Add query parameters for pagination and sorting @@ -1267,22 +1241,61 @@ async function listIssueDiscussions( if (options.per_page) { url.searchParams.append("per_page", options.per_page.toString()); } - if (options.sort) { - url.searchParams.append("sort", options.sort); - } - if (options.order_by) { - url.searchParams.append("order_by", options.order_by); - } const response = await fetch(url.toString(), { ...DEFAULT_FETCH_CONFIG, }); await handleGitLabError(response); - const data = await response.json(); + const discussions = await response.json(); - // Parse the response as an array of discussions - return z.array(GitLabDiscussionSchema).parse(data); + // Extract pagination headers + const pagination = { + x_next_page: response.headers.get("x-next-page") ? parseInt(response.headers.get("x-next-page")!) : null, + x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, + x_per_page: response.headers.get("x-per-page") ? parseInt(response.headers.get("x-per-page")!) : undefined, + x_prev_page: response.headers.get("x-prev-page") ? parseInt(response.headers.get("x-prev-page")!) : null, + x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, + x_total_pages: response.headers.get("x-total-pages") ? parseInt(response.headers.get("x-total-pages")!) : null, + }; + + return PaginatedDiscussionsResponseSchema.parse({ + items: discussions, + pagination: pagination, + }); +} + +/** + * List merge request discussion items + * 병합 요청 토론 목록 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The IID of a merge request + * @param {DiscussionPaginationOptions} options - Pagination and sorting options + * @returns {Promise} List of discussions + */ +async function listMergeRequestDiscussions( + projectId: string, + mergeRequestIid: number, + options: PaginationOptions = {} +): Promise { + return listDiscussions(projectId, "merge_requests", mergeRequestIid, options); +} + +/** + * List discussions for an issue + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @param {DiscussionPaginationOptions} options - Pagination and sorting options + * @returns {Promise} List of issue discussions + */ +async function listIssueDiscussions( + projectId: string, + issueIid: number, + options: PaginationOptions = {} +): Promise { + return listDiscussions(projectId, "issues", issueIid, options); } /** @@ -3284,9 +3297,11 @@ server.setRequestHandler(CallToolRequestSchema, async request => { case "mr_discussions": { const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, ...options } = args; const discussions = await listMergeRequestDiscussions( - args.project_id, - args.merge_request_iid + project_id, + merge_request_iid, + options ); return { content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], diff --git a/schemas.ts b/schemas.ts index 96da01a..f669f1d 100644 --- a/schemas.ts +++ b/schemas.ts @@ -94,6 +94,13 @@ export const GitLabPipelineJobSchema = z.object({ web_url: z.string().optional(), }); +// Shared base schema for various pagination options +// See https://docs.gitlab.com/api/rest/#pagination +export const PaginationOptionsSchema = z.object({ + page: z.number().optional().describe("Page number for pagination (default: 1)"), + per_page: z.number().optional().describe("Number of items per page (max: 100, default: 20)"), +}); + // Schema for listing pipelines export const ListPipelinesSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), @@ -134,9 +141,7 @@ export const ListPipelinesSchema = z.object({ .optional() .describe("Order pipelines by"), sort: z.enum(["asc", "desc"]).optional().describe("Sort pipelines"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page (max 100)"), -}); +}).merge(PaginationOptionsSchema); // Schema for getting a specific pipeline export const GetPipelineSchema = z.object({ @@ -153,9 +158,7 @@ export const ListPipelineJobsSchema = z.object({ .optional() .describe("The scope of jobs to show"), include_retried: z.boolean().optional().describe("Whether to include retried jobs"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page (max 100)"), -}); +}).merge(PaginationOptionsSchema); // Schema for creating a new pipeline export const CreatePipelineSchema = z.object({ @@ -698,6 +701,24 @@ export const GitLabDiscussionNoteSchema = z.object({ }); export type GitLabDiscussionNote = z.infer; +// Reusable pagination schema for GitLab API responses. +// See https://docs.gitlab.com/api/rest/#pagination +export const GitLabPaginationSchema = z.object({ + x_next_page: z.number().nullable().optional(), + x_page: z.number().optional(), + x_per_page: z.number().optional(), + x_prev_page: z.number().nullable().optional(), + x_total: z.number().nullable().optional(), + x_total_pages: z.number().nullable().optional(), +}); +export type GitLabPagination = z.infer; + +// Base paginated response schema that can be extended. +// See https://docs.gitlab.com/api/rest/#pagination +export const PaginatedResponseSchema = z.object({ + pagination: GitLabPaginationSchema.optional(), +}); + export const GitLabDiscussionSchema = z.object({ id: z.string(), individual_note: z.boolean(), @@ -705,10 +726,24 @@ export const GitLabDiscussionSchema = z.object({ }); export type GitLabDiscussion = z.infer; +// Create a schema for paginated discussions response +export const PaginatedDiscussionsResponseSchema = z.object({ + items: z.array(GitLabDiscussionSchema), + pagination: GitLabPaginationSchema, +}); + +// Export the paginated response type for discussions +export type PaginatedDiscussionsResponse = z.infer; + +export const ListIssueDiscussionsSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + issue_iid: z.number().describe("The internal ID of the project issue"), +}).merge(PaginationOptionsSchema); + // Input schema for listing merge request discussions export const ListMergeRequestDiscussionsSchema = ProjectParamsSchema.extend({ merge_request_iid: z.number().describe("The IID of a merge request"), -}); +}).merge(PaginationOptionsSchema); // Input schema for updating a merge request discussion note export const UpdateMergeRequestNoteSchema = ProjectParamsSchema.extend({ @@ -763,9 +798,7 @@ export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({ export const SearchRepositoriesSchema = z.object({ search: z.string().describe("Search query"), // Changed from query to match GitLab API - page: z.number().optional().describe("Page number for pagination (default: 1)"), - per_page: z.number().optional().describe("Number of results per page (default: 20)"), -}); +}).merge(PaginationOptionsSchema); export const CreateRepositorySchema = z.object({ name: z.string().describe("Repository name"), @@ -904,9 +937,7 @@ export const ListIssuesSchema = z.object({ updated_after: z.string().optional().describe("Return issues updated after the given time"), updated_before: z.string().optional().describe("Return issues updated before the given time"), with_labels_details: z.boolean().optional().describe("Return more details for each label"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), -}); +}).merge(PaginationOptionsSchema); // Merge Requests API operation schemas export const ListMergeRequestsSchema = z.object({ @@ -977,9 +1008,7 @@ export const ListMergeRequestsSchema = z.object({ .describe("Return merge requests from a specific source branch"), wip: z.enum(["yes", "no"]).optional().describe("Filter merge requests against their wip status"), with_labels_details: z.boolean().optional().describe("Return more details for each label"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), -}); +}).merge(PaginationOptionsSchema); export const GetIssueSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), @@ -1018,21 +1047,6 @@ export const ListIssueLinksSchema = z.object({ issue_iid: z.number().describe("The internal ID of a project's issue"), }); -export const ListIssueDiscussionsSchema = z.object({ - project_id: z.string().describe("Project ID or URL-encoded path"), - issue_iid: z.number().describe("The internal ID of the project issue"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), - sort: z - .enum(["asc", "desc"]) - .optional() - .describe("Return issue discussions sorted in ascending or descending order"), - order_by: z - .enum(["created_at", "updated_at"]) - .optional() - .describe("Return issue discussions ordered by created_at or updated_at fields"), -}); - export const GetIssueLinkSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), issue_iid: z.number().describe("The internal ID of a project's issue"), @@ -1059,10 +1073,8 @@ export const DeleteIssueLinkSchema = z.object({ // Namespace API operation schemas export const ListNamespacesSchema = z.object({ search: z.string().optional().describe("Search term for namespaces"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), owned: z.boolean().optional().describe("Filter for namespaces owned by current user"), -}); +}).merge(PaginationOptionsSchema); export const GetNamespaceSchema = z.object({ namespace_id: z.string().describe("Namespace ID or full path"), @@ -1079,8 +1091,6 @@ export const GetProjectSchema = z.object({ export const ListProjectsSchema = z.object({ search: z.string().optional().describe("Search term for projects"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), search_namespaces: z.boolean().optional().describe("Needs to be true if search is full path"), owned: z.boolean().optional().describe("Filter for projects owned by current user"), membership: z.boolean().optional().describe("Filter for projects where current user is a member"), @@ -1107,7 +1117,7 @@ export const ListProjectsSchema = z.object({ .optional() .describe("Filter projects with merge requests feature enabled"), min_access_level: z.number().optional().describe("Filter by minimum access level"), -}); +}).merge(PaginationOptionsSchema); // Label operation schemas export const ListLabelsSchema = z.object({ @@ -1163,8 +1173,6 @@ export const ListGroupProjectsSchema = z.object({ .optional() .describe("Field to sort by"), sort: z.enum(["asc", "desc"]).optional().describe("Sort direction"), - page: z.number().optional().describe("Page number"), - per_page: z.number().optional().describe("Number of results per page"), archived: z.boolean().optional().describe("Filter for archived projects"), visibility: z .enum(["public", "internal", "private"]) @@ -1184,14 +1192,12 @@ export const ListGroupProjectsSchema = z.object({ statistics: z.boolean().optional().describe("Include project statistics"), with_custom_attributes: z.boolean().optional().describe("Include custom attributes"), with_security_reports: z.boolean().optional().describe("Include security reports"), -}); +}).merge(PaginationOptionsSchema); // Add wiki operation schemas export const ListWikiPagesSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), -}); +}).merge(PaginationOptionsSchema); export const GetWikiPageSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), slug: z.string().describe("URL-encoded slug of the wiki page"), @@ -1209,6 +1215,7 @@ export const UpdateWikiPageSchema = z.object({ content: z.string().optional().describe("New content of the wiki page"), format: z.string().optional().describe("Content format, e.g., markdown, rdoc"), }); + export const DeleteWikiPageSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), slug: z.string().describe("URL-encoded slug of the wiki page"), @@ -1275,9 +1282,7 @@ export const ListProjectMilestonesSchema = ProjectParamsSchema.extend({ .string() .optional() .describe("Return milestones updated after the specified date (ISO 8601 format)"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page (max 100)"), -}); +}).merge(PaginationOptionsSchema); // Schema for getting a single milestone export const GetProjectMilestoneSchema = ProjectParamsSchema.extend({ @@ -1311,19 +1316,13 @@ export const DeleteProjectMilestoneSchema = GetProjectMilestoneSchema; export const GetMilestoneIssuesSchema = GetProjectMilestoneSchema; // Schema for getting merge requests assigned to a milestone -export const GetMilestoneMergeRequestsSchema = GetProjectMilestoneSchema.extend({ - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page (max 100)"), -}); +export const GetMilestoneMergeRequestsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema); // Schema for promoting a project milestone to a group milestone export const PromoteProjectMilestoneSchema = GetProjectMilestoneSchema; // Schema for getting burndown chart events for a milestone -export const GetMilestoneBurndownEventsSchema = GetProjectMilestoneSchema.extend({ - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page (max 100)"), -}); +export const GetMilestoneBurndownEventsSchema = GetProjectMilestoneSchema.merge(PaginationOptionsSchema); // Export types export type GitLabAuthor = z.infer; @@ -1352,6 +1351,7 @@ export type GitLabMergeRequestDiff = z.infer< export type CreateNoteOptions = z.infer; export type GitLabIssueLink = z.infer; export type ListIssueDiscussionsOptions = z.infer; +export type ListMergeRequestDiscussionsOptions = z.infer; export type UpdateIssueNoteOptions = z.infer; export type CreateIssueNoteOptions = z.infer; export type GitLabNamespace = z.infer; @@ -1389,3 +1389,4 @@ export type PromoteProjectMilestoneOptions = z.infer; export type GitLabUser = z.infer; export type GitLabUsersResponse = z.infer; +export type PaginationOptions = z.infer;