diff --git a/README.md b/README.md index 9285539..17bba0a 100644 --- a/README.md +++ b/README.md @@ -137,14 +137,17 @@ $ sh scripts/image_push.sh docker_user_name 48. `list_pipeline_jobs` - List all jobs in a specific pipeline 49. `get_pipeline_job` - Get details of a GitLab pipeline job number 50. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job number -51. `list_merge_requests` - List merge requests in a GitLab project with filtering options -52. `list_milestones` - List milestones in a GitLab project with filtering options -53. `get_milestone` - Get details of a specific milestone -54. `create_milestone` - Create a new milestone in a GitLab project -55. `edit_milestone ` - Edit an existing milestone in a GitLab project -56. `delete_milestone` - Delete a milestone from a GitLab project -57. `get_milestone_issue` - Get issues associated with a specific milestone -58. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone -59. `promote_milestone` - Promote a milestone to the next stage -60. `get_milestone_burndown_events` - Get burndown events for a specific milestone +51. `create_pipeline` - Create a new pipeline for a branch or tag +52. `retry_pipeline` - Retry a failed or canceled pipeline +53. `cancel_pipeline` - Cancel a running pipeline +54. `list_merge_requests` - List merge requests in a GitLab project with filtering options +55. `list_milestones` - List milestones in a GitLab project with filtering options +56. `get_milestone` - Get details of a specific milestone +57. `create_milestone` - Create a new milestone in a GitLab project +58. `edit_milestone ` - Edit an existing milestone in a GitLab project +59. `delete_milestone` - Delete a milestone from a GitLab project +60. `get_milestone_issue` - Get issues associated with a specific milestone +61. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone +62. `promote_milestone` - Promote a milestone to the next stage +63. `get_milestone_burndown_events` - Get burndown events for a specific milestone diff --git a/index.ts b/index.ts index 0f676aa..0e51645 100644 --- a/index.ts +++ b/index.ts @@ -86,6 +86,9 @@ import { GetPipelineSchema, ListPipelinesSchema, ListPipelineJobsSchema, + CreatePipelineSchema, + RetryPipelineSchema, + CancelPipelineSchema, // pipeline job schemas GetPipelineJobOutputSchema, GitLabPipelineJobSchema, @@ -117,6 +120,9 @@ import { type ListPipelinesOptions, type GetPipelineOptions, type ListPipelineJobsOptions, + type CreatePipelineOptions, + type RetryPipelineOptions, + type CancelPipelineOptions, type GitLabPipelineJob, type GitLabMilestones, type ListProjectMilestonesOptions, @@ -482,6 +488,21 @@ const allTools = [ description: "Get the output/trace of a GitLab pipeline job number", inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), }, + { + name: "create_pipeline", + description: "Create a new pipeline for a branch or tag", + inputSchema: zodToJsonSchema(CreatePipelineSchema), + }, + { + name: "retry_pipeline", + description: "Retry a failed or canceled pipeline", + inputSchema: zodToJsonSchema(RetryPipelineSchema), + }, + { + name: "cancel_pipeline", + description: "Cancel a running pipeline", + inputSchema: zodToJsonSchema(CancelPipelineSchema), + }, { name: "list_merge_requests", description: "List merge requests in a GitLab project with filtering options", @@ -2484,6 +2505,87 @@ async function getPipelineJobOutput(projectId: string, jobId: number): Promise} The created pipeline + */ +async function createPipeline( + projectId: string, + ref: string, + variables?: Array<{ key: string; value: string }> +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/pipeline`); + + const body: any = { ref }; + if (variables && variables.length > 0) { + body.variables = variables.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {} as Record); + } + + const response = await fetch(url.toString(), { + method: "POST", + headers: DEFAULT_HEADERS, + body: JSON.stringify(body), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + +/** + * Retry a pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} pipelineId - The ID of the pipeline to retry + * @returns {Promise} The retried pipeline + */ +async function retryPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/retry` + ); + + const response = await fetch(url.toString(), { + method: "POST", + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + +/** + * Cancel a pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} pipelineId - The ID of the pipeline to cancel + * @returns {Promise} The canceled pipeline + */ +async function cancelPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/cancel` + ); + + const response = await fetch(url.toString(), { + method: "POST", + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + /** * Get the repository tree for a project * @param {string} projectId - The ID or URL-encoded path of the project @@ -3409,6 +3511,45 @@ server.setRequestHandler(CallToolRequestSchema, async request => { }; } + case "create_pipeline": { + const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); + const pipeline = await createPipeline(project_id, ref, variables); + return { + content: [ + { + type: "text", + text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "retry_pipeline": { + const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); + const pipeline = await retryPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "cancel_pipeline": { + const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); + const pipeline = await cancelPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + case "list_merge_requests": { const args = ListMergeRequestsSchema.parse(request.params.arguments); const mergeRequests = await listMergeRequests(args.project_id, args); diff --git a/schemas.ts b/schemas.ts index c6aeee4..4450e01 100644 --- a/schemas.ts +++ b/schemas.ts @@ -157,6 +157,33 @@ export const ListPipelineJobsSchema = z.object({ per_page: z.number().optional().describe("Number of items per page (max 100)"), }); +// Schema for creating a new pipeline +export const CreatePipelineSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + ref: z.string().describe("The branch or tag to run the pipeline on"), + variables: z + .array( + z.object({ + key: z.string().describe("The key of the variable"), + value: z.string().describe("The value of the variable"), + }) + ) + .optional() + .describe("An array of variables to use for the pipeline"), +}); + +// Schema for retrying a pipeline +export const RetryPipelineSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + pipeline_id: z.number().describe("The ID of the pipeline to retry"), +}); + +// Schema for canceling a pipeline +export const CancelPipelineSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + pipeline_id: z.number().describe("The ID of the pipeline to cancel"), +}); + // Schema for the input parameters for pipeline job operations export const GetPipelineJobOutputSchema = z.object({ project_id: z.string().describe("Project ID or URL-encoded path"), @@ -1281,6 +1308,9 @@ export type GitLabPipeline = z.infer; export type ListPipelinesOptions = z.infer; export type GetPipelineOptions = z.infer; export type ListPipelineJobsOptions = z.infer; +export type CreatePipelineOptions = z.infer; +export type RetryPipelineOptions = z.infer; +export type CancelPipelineOptions = z.infer; export type GitLabMilestones = z.infer; export type ListProjectMilestonesOptions = z.infer; export type GetProjectMilestoneOptions = z.infer; diff --git a/test/validate-api.js b/test/validate-api.js index 2becbc2..db5dab8 100755 --- a/test/validate-api.js +++ b/test/validate-api.js @@ -43,9 +43,15 @@ async function validateGitLabAPI() { url: `${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(TEST_PROJECT_ID)}/repository/branches?per_page=1`, validate: data => Array.isArray(data), }, + { + name: "List pipelines", + url: `${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(TEST_PROJECT_ID)}/pipelines?per_page=5`, + validate: data => Array.isArray(data), + }, ]; let allPassed = true; + let firstPipelineId = null; for (const test of tests) { try { @@ -65,6 +71,11 @@ async function validateGitLabAPI() { if (test.validate(data)) { console.log(`✅ ${test.name} - PASSED\n`); + + // If we found pipelines, save the first one for additional testing + if (test.name === "List pipelines" && data.length > 0) { + firstPipelineId = data[0].id; + } } else { console.log(`❌ ${test.name} - FAILED (invalid response format)\n`); allPassed = false; @@ -76,6 +87,53 @@ async function validateGitLabAPI() { } } + // Test pipeline-specific endpoints if we have a pipeline ID + if (firstPipelineId) { + console.log(`Found pipeline #${firstPipelineId}, testing pipeline-specific endpoints...\n`); + + const pipelineTests = [ + { + name: `Get pipeline #${firstPipelineId} details`, + url: `${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(TEST_PROJECT_ID)}/pipelines/${firstPipelineId}`, + validate: data => data.id === firstPipelineId && data.status, + }, + { + name: `List pipeline #${firstPipelineId} jobs`, + url: `${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(TEST_PROJECT_ID)}/pipelines/${firstPipelineId}/jobs`, + validate: data => Array.isArray(data), + }, + ]; + + for (const test of pipelineTests) { + try { + console.log(`Testing: ${test.name}`); + const response = await fetch(test.url, { + headers: { + Authorization: `Bearer ${GITLAB_TOKEN}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (test.validate(data)) { + console.log(`✅ ${test.name} - PASSED\n`); + } else { + console.log(`❌ ${test.name} - FAILED (invalid response format)\n`); + allPassed = false; + } + } catch (error) { + console.log(`❌ ${test.name} - FAILED`); + console.log(` Error: ${error.message}\n`); + allPassed = false; + } + } + } + if (allPassed) { console.log("✅ All API validation tests passed!"); } else {