diff --git a/README.md b/README.md index eabec24..e5de941 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Give your AI coding agents full visibility into your CI test results. The Curren | `currents-list-pull-requests` | List pull-request cards for a project (runs grouped by meta.pr.id). | | `currents-list-project-terms` | List cursor-paginated project terms for one type (tag, branch, authorName, etc.). | | `currents-create-jira-issue` | Create a Jira issue from a run test using the organization Jira integration. | +| `currents-link-jira-issue` | Link an existing Jira issue to a run test using the organization Jira integration. | | `currents-list-jira-projects` | List Jira projects available for the organization integration. | | `currents-list-jira-issue-types` | List Jira issue types and custom fields for a Jira project. | | `currents-get-runs` | Retrieves a list of runs for a specific project with optional filtering. | diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index 0589129..5258d7c 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased (2026-06-12) + +### Added + +- MCP tool `currents-link-jira-issue` for `POST /projects/{projectId}/jira/issues/{jiraIssueKey}/link` (link an existing Jira issue to a run test). + ## Unreleased (2026-05-27) ### Added diff --git a/mcp-server/src/server.ts b/mcp-server/src/server.ts index bfd4da9..5793053 100644 --- a/mcp-server/src/server.ts +++ b/mcp-server/src/server.ts @@ -20,6 +20,7 @@ import { updateActionTool } from "./tools/actions/update-action.js"; import { getContextTool } from "./tools/context/get-context.js"; // Integrations tools import { createJiraIssueFromRunTestTool } from "./tools/integrations/create-jira-issue.js"; +import { linkJiraIssueFromRunTestTool } from "./tools/integrations/link-jira-issue.js"; import { listJiraIssueTypesTool } from "./tools/integrations/list-jira-issue-types.js"; import { listJiraProjectsTool } from "./tools/integrations/list-jira-projects.js"; // Projects tools @@ -233,6 +234,16 @@ server.registerTool( createJiraIssueFromRunTestTool.handler, ); +server.registerTool( + "currents-link-jira-issue", + { + description: + "Link an existing Jira issue to a run test using the organization Jira integration. Requires projectId, jiraIssueKey, runId, testId, jiraInstallationId, jiraProjectId, and jiraIssueType. Optional comment and includeContextInComment.", + inputSchema: linkJiraIssueFromRunTestTool.schema, + }, + linkJiraIssueFromRunTestTool.handler, +); + server.registerTool( "currents-list-jira-projects", { diff --git a/mcp-server/src/tools/integrations/link-jira-issue.test.ts b/mcp-server/src/tools/integrations/link-jira-issue.test.ts new file mode 100644 index 0000000..4dfd1b5 --- /dev/null +++ b/mcp-server/src/tools/integrations/link-jira-issue.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import * as request from "../../lib/request.js"; +import { linkJiraIssueFromRunTestTool } from "./link-jira-issue.js"; + +describe("linkJiraIssueFromRunTestTool", () => { + it("calls POST /projects/{projectId}/jira/issues/{jiraIssueKey}/link with required body fields", async () => { + vi.spyOn(request, "postApi").mockResolvedValue({ status: "OK", data: {} }); + + await linkJiraIssueFromRunTestTool.handler({ + projectId: "p1", + jiraIssueKey: "PROJ-42", + runId: "run-1", + testId: "test-1", + jiraInstallationId: "inst-1", + jiraProjectId: "10000", + jiraIssueType: "10001", + }); + + expect(request.postApi).toHaveBeenCalledWith( + "/projects/p1/jira/issues/PROJ-42/link", + { + runId: "run-1", + testId: "test-1", + jiraInstallationId: "inst-1", + jiraProjectId: "10000", + jiraIssueType: "10001", + } + ); + }); + + it("includes optional comment and includeContextInComment in request body", async () => { + vi.spyOn(request, "postApi").mockResolvedValue({ status: "OK", data: {} }); + + await linkJiraIssueFromRunTestTool.handler({ + projectId: "p1", + jiraIssueKey: "PROJ-42", + runId: "run-1", + testId: "test-1", + jiraInstallationId: "inst-1", + jiraProjectId: "10000", + jiraIssueType: "10001", + comment: "Manual note", + includeContextInComment: false, + }); + + expect(request.postApi).toHaveBeenCalledWith( + "/projects/p1/jira/issues/PROJ-42/link", + expect.objectContaining({ + comment: "Manual note", + includeContextInComment: false, + }) + ); + }); + + it("schema requires comment when includeContextInComment is false", () => { + const result = linkJiraIssueFromRunTestTool.schema.safeParse({ + projectId: "p1", + jiraIssueKey: "PROJ-42", + runId: "run-1", + testId: "test-1", + jiraInstallationId: "inst-1", + jiraProjectId: "10000", + jiraIssueType: "10001", + includeContextInComment: false, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/mcp-server/src/tools/integrations/link-jira-issue.ts b/mcp-server/src/tools/integrations/link-jira-issue.ts new file mode 100644 index 0000000..91c1095 --- /dev/null +++ b/mcp-server/src/tools/integrations/link-jira-issue.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { postApi } from "../../lib/request.js"; +import { logger } from "../../lib/logger.js"; + +const zodSchema = z + .object({ + projectId: z.string().describe("Currents project ID."), + jiraIssueKey: z + .string() + .min(1) + .describe("Existing Jira issue key to link (e.g. PROJ-123)."), + runId: z.string().min(1).describe("Currents run ID containing the test."), + testId: z.string().min(1).describe("Test ID within the run."), + jiraInstallationId: z + .string() + .min(1) + .describe("Jira installation ID for the org integration (dashboard Installation ID)."), + jiraProjectId: z.string().min(1).describe("Jira project ID for the linked issue."), + jiraIssueType: z + .string() + .min(1) + .describe("Jira issue type identifier stored on the Currents ticket."), + comment: z + .string() + .optional() + .describe( + "Optional text prepended to the Jira comment and Currents issue description before automated test context." + ), + includeContextInComment: z + .boolean() + .optional() + .describe( + "When true (default), appends automated test context to the Jira comment. When false, comment is required and used alone." + ), + }) + .superRefine((val, ctx) => { + if (val.includeContextInComment === false && !val.comment?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "comment is required when includeContextInComment is false", + path: ["comment"], + }); + } + }); + +const handler = async ({ + projectId, + jiraIssueKey, + runId, + testId, + jiraInstallationId, + jiraProjectId, + jiraIssueType, + comment, + includeContextInComment, +}: z.infer) => { + const body: Record = { + runId, + testId, + jiraInstallationId, + jiraProjectId, + jiraIssueType, + }; + if (comment !== undefined) { + body.comment = comment; + } + if (includeContextInComment !== undefined) { + body.includeContextInComment = includeContextInComment; + } + + const path = `/projects/${encodeURIComponent(projectId)}/jira/issues/${encodeURIComponent(jiraIssueKey)}/link`; + logger.info(`Linking Jira issue: ${path}`); + + const data = await postApi(path, body); + if (!data) { + return { + content: [{ type: "text" as const, text: "Failed to link Jira issue" }], + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; +}; + +export const linkJiraIssueFromRunTestTool = { + schema: zodSchema, + handler, +};