From 6b58938a0d064cb060ef9cfe673ca938213192c0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:03:54 +0000 Subject: [PATCH 1/4] feat(mcp): Resolve issues in a release or commit with update_issue Add optional inRelease, inCommit, and inCommitRepository params to update_issue and send them as statusDetails, matching what the Sentry issue update API already accepts. Co-Authored-By: Bruno Garcia <1633368+bruno-garcia@users.noreply.github.com> --- packages/mcp-core/src/api-client/client.ts | 5 + packages/mcp-core/src/api-client/types.ts | 8 + packages/mcp-core/src/schema.ts | 24 ++ packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 17 +- .../src/tools/catalog/update-issue.test.ts | 281 ++++++++++++++++++ .../src/tools/catalog/update-issue.ts | 194 ++++++++++-- packages/mcp-server-mocks/src/index.ts | 14 + 8 files changed, 526 insertions(+), 19 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index acc0d2629..16eabf38a 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -232,6 +232,7 @@ import type { IssueCommentList, IssueIntegrationList, IssueList, + IssueResolutionDetails, IssueTagKeyList, IssueTagValues, MetricAlertRule, @@ -4122,6 +4123,7 @@ export class SentryApiService { ignoreWindow, ignoreUserCount, ignoreUserWindow, + statusDetails, }: { organizationSlug: string; issueId: string; @@ -4133,6 +4135,7 @@ export class SentryApiService { ignoreWindow?: number; ignoreUserCount?: number; ignoreUserWindow?: number; + statusDetails?: IssueResolutionDetails; }, opts?: RequestOptions, ): Promise { @@ -4145,8 +4148,10 @@ export class SentryApiService { ignoreWindow?: number; ignoreUserCount?: number; ignoreUserWindow?: number; + statusDetails?: IssueResolutionDetails; } = {}; if (status !== undefined) updateData.status = status; + if (statusDetails !== undefined) updateData.statusDetails = statusDetails; if (assignedTo !== undefined) updateData.assignedTo = assignedTo ?? ""; if (substatus !== undefined) updateData.substatus = substatus; if (ignoreDuration !== undefined) diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 18936720f..38ec7ab84 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -214,6 +214,14 @@ export type Commit = z.infer; export type Issue = z.infer; export type IssueActivity = z.infer; export type IssueComment = z.infer; + +/** + * `statusDetails` that resolve an issue in a specific release or commit. + * Sentry validates both against its own release and commit records. + */ +export type IssueResolutionDetails = + | { inRelease: string } + | { inCommit: { commit: string; repository: string } }; export type Monitor = z.infer; export type MonitorCheckIn = z.infer; export type MonitorStat = z.infer; diff --git a/packages/mcp-core/src/schema.ts b/packages/mcp-core/src/schema.ts index 2a3162761..9f45ae2a4 100644 --- a/packages/mcp-core/src/schema.ts +++ b/packages/mcp-core/src/schema.ts @@ -278,6 +278,30 @@ export const ParamIgnoreUserWindowMinutes = z "Optional time window in minutes for ignoreUserCount. If omitted, Sentry counts all future affected users.", ); +export const ParamInRelease = z + .string() + .trim() + .min(1) + .describe( + "Release version to resolve the issue in, such as a version returned by find_releases. Requires status='resolved'. Events from newer releases reopen the issue as a regression; events from older releases do not.", + ); + +export const ParamInCommit = z + .string() + .trim() + .min(1) + .describe( + "SHA of the commit that fixes the issue. Requires status='resolved' and inCommitRepository. The commit must already be known to Sentry.", + ); + +export const ParamInCommitRepository = z + .string() + .trim() + .min(1) + .describe( + "Name of the repository that contains inCommit, as it appears in Sentry (for example 'getsentry/sentry').", + ); + export const ParamReason = z .string() .overwrite((s) => s.replace(/\0/g, "")) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index bf5f5e664..3c0738b4f 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -525,7 +525,7 @@ }, { "name": "update_issue", - "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, unassign, or ignore an issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo=null)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- Omit `assignedTo` to leave assignment unchanged. Pass `null` to unassign; otherwise use `user:ID` or `team:ID_OR_SLUG`.\n- Use the `whoami` tool to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n", + "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, unassign, or ignore an issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo=null)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- Omit `assignedTo` to leave assignment unchanged. Pass `null` to unassign; otherwise use `user:ID` or `team:ID_OR_SLUG`.\n- Use the `whoami` tool to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='resolved'` takes optional `inRelease`, or `inCommit` + `inCommitRepository`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n", "requiredScopes": ["event:write"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 8876e1d16..60a361ab3 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -14405,7 +14405,7 @@ { "name": "update_issue", "title": "Update Issue", - "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, unassign, or ignore an issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo=null)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- Omit `assignedTo` to leave assignment unchanged. Pass `null` to unassign; otherwise use `user:ID` or `team:ID_OR_SLUG`.\n- Use the `whoami` tool to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n", + "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, unassign, or ignore an issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo=null)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- Omit `assignedTo` to leave assignment unchanged. Pass `null` to unassign; otherwise use `user:ID` or `team:ID_OR_SLUG`.\n- Use the `whoami` tool to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='resolved'` takes optional `inRelease`, or `inCommit` + `inCommitRepository`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n", "inputSchema": { "type": "object", "properties": { @@ -14497,6 +14497,21 @@ "maximum": 10080, "description": "Optional time window in minutes for ignoreUserCount. If omitted, Sentry counts all future affected users." }, + "inRelease": { + "type": "string", + "minLength": 1, + "description": "Release version to resolve the issue in, such as a version returned by find_releases. Requires status='resolved'. Events from newer releases reopen the issue as a regression; events from older releases do not." + }, + "inCommit": { + "type": "string", + "minLength": 1, + "description": "SHA of the commit that fixes the issue. Requires status='resolved' and inCommitRepository. The commit must already be known to Sentry." + }, + "inCommitRepository": { + "type": "string", + "minLength": 1, + "description": "Name of the repository that contains inCommit, as it appears in Sentry (for example 'getsentry/sentry')." + }, "reason": { "type": "string", "minLength": 1, diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 0f94966b9..9de9b22fc 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -469,6 +469,287 @@ describe("update_issue", () => { expect(output.current.status).toBe("resolvedInNextRelease"); }); + it("resolves an issue in a specific release", async () => { + let putBody: unknown; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => HttpResponse.json(createIssue({ status: "unresolved" })), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json( + createIssue({ + status: "resolved", + statusDetails: { inRelease: "backend@1.4.2" }, + }), + ); + }, + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inRelease: "backend@1.4.2", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(putBody).toEqual({ + status: "resolved", + statusDetails: { inRelease: "backend@1.4.2" }, + }); + expect(structured(result)).toMatchInlineSnapshot(` + { + "changes": { + "assignedTo": null, + "ignoreBehavior": null, + "status": { + "from": "unresolved", + "to": "resolvedInRelease", + }, + }, + "current": { + "assignedTo": "Jane Developer", + "ignoreBehavior": null, + "status": "resolvedInRelease", + }, + "effect": "The issue is now marked as resolved in release backend@1.4.2. Events from newer releases will reopen it as a regression", + "issueId": "CLOUDFLARE-MCP-41", + "issueUrl": "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", + "organizationSlug": "sentry-mcp-evals", + "reasonComment": null, + "title": "Error: Tool list_organizations is already registered", + "updated": true, + } + `); + }); + + it("resolves an issue in a commit", async () => { + let putBody: unknown; + const sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => HttpResponse.json(createIssue({ status: "unresolved" })), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json( + createIssue({ + status: "resolved", + statusDetails: { + inCommit: { id: sha, repository: { name: "my-org/backend" } }, + }, + }), + ); + }, + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inCommit: sha, + inCommitRepository: "my-org/backend", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(putBody).toEqual({ + status: "resolved", + statusDetails: { + inCommit: { commit: sha, repository: "my-org/backend" }, + }, + }); + const output = structured(result); + expect(output.changes.status).toEqual({ + from: "unresolved", + to: "resolvedInCommit", + }); + expect(output.effect).toBe( + `The issue is now marked as resolved in commit ${sha}. It will be resolved in the first release that contains this commit`, + ); + }); + + it("skips updates when the issue is already resolved in the requested release", async () => { + let putCalled = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => + HttpResponse.json( + createIssue({ + status: "resolved", + statusDetails: { inRelease: "backend@1.4.2" }, + }), + ), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + putCalled = true; + return HttpResponse.json(createIssue()); + }, + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inRelease: "backend@1.4.2", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(putCalled).toBe(false); + const output = structured(result); + expect(output.updated).toBe(false); + expect(output.current.status).toBe("resolvedInRelease"); + }); + + it("rejects a release resolution for an issue that is already resolved", async () => { + let putCalled = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => + HttpResponse.json( + createIssue({ status: "resolved", statusDetails: {} }), + ), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + putCalled = true; + return HttpResponse.json(createIssue()); + }, + ), + ); + + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inRelease: "backend@1.4.2", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), + ).rejects.toThrow( + "The issue is already resolved (`resolved`). To resolve it in a different release or commit, first set `status` to `unresolved`, then resolve it again.", + ); + expect(putCalled).toBe(false); + }); + + it("skips a plain resolve when the issue is already resolved in a release", async () => { + let putCalled = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => + HttpResponse.json( + createIssue({ + status: "resolved", + statusDetails: { inRelease: "backend@1.4.2" }, + }), + ), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + putCalled = true; + return HttpResponse.json(createIssue()); + }, + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(putCalled).toBe(false); + expect(structured(result).updated).toBe(false); + }); + + it.each([ + { + name: "inRelease without resolved status", + params: { status: "resolvedInNextRelease" as const, inRelease: "1.0.0" }, + error: + "`inRelease` and `inCommit` can only be used when `status` is `resolved`", + }, + { + name: "both inRelease and inCommit", + params: { + status: "resolved" as const, + inRelease: "1.0.0", + inCommit: "abc", + inCommitRepository: "my-org/backend", + }, + error: "Choose only one of `inRelease` or `inCommit`", + }, + { + name: "inCommit without inCommitRepository", + params: { status: "resolved" as const, inCommit: "abc" }, + error: "`inCommit` requires `inCommitRepository`", + }, + { + name: "inCommitRepository without inCommit", + params: { + status: "resolved" as const, + inCommitRepository: "my-org/backend", + }, + error: "`inCommitRepository` requires `inCommit`", + }, + ])("rejects $name", async ({ params, error }) => { + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + ...params, + }, + serverContext, + ), + ).rejects.toThrow(error); + }); + it("assigns issue to team by slug", async () => { const result = await updateIssue.handler( { diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 96832756c..0a7655a0a 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import type { AssignedTo, Issue } from "../../api-client/types"; +import type { + AssignedTo, + Issue, + IssueResolutionDetails, +} from "../../api-client/types"; import { UserInputError } from "../../errors"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; @@ -16,6 +20,9 @@ import { ParamIgnoreUserCount, ParamIgnoreUserWindowMinutes, ParamIgnoreWindowMinutes, + ParamInCommit, + ParamInCommitRepository, + ParamInRelease, ParamIssueIgnoreMode, ParamIssueShortId, ParamIssueStatus, @@ -63,6 +70,8 @@ type IgnoreState = { type IssueStatusDisplay = | "resolved" | "resolvedInNextRelease" + | "resolvedInRelease" + | "resolvedInCommit" | "unresolved" | "ignored" | (string & {}); @@ -158,6 +167,21 @@ function getStatusDetailString( return undefined; } +/** SHA of the commit an issue is resolved in, from the serialized commit. */ +function getResolvedCommitId( + statusDetails: Record, +): string | undefined { + const commit = statusDetails.inCommit; + if (commit && typeof commit === "object" && !Array.isArray(commit)) { + const id = (commit as Record).id; + if (typeof id === "string" && id.length > 0) { + return id; + } + } + + return undefined; +} + function formatIgnoreUntil(ignoreUntil: string): string { const date = new Date(ignoreUntil); if (Number.isNaN(date.getTime())) { @@ -170,13 +194,138 @@ function formatIgnoreUntil(ignoreUntil: string): string { function getIssueStatusDisplay(issue: Issue): IssueStatusDisplay { const statusDetails = getIssueStatusDetails(issue); - if (issue.status === "resolved" && statusDetails.inNextRelease === true) { - return "resolvedInNextRelease"; + if (issue.status === "resolved") { + if (statusDetails.inNextRelease === true) { + return "resolvedInNextRelease"; + } + if (getStatusDetailString(statusDetails, "inRelease")) { + return "resolvedInRelease"; + } + if (getResolvedCommitId(statusDetails)) { + return "resolvedInCommit"; + } } return issue.status; } +type ResolutionParams = { + status?: string; + inRelease?: string; + inCommit?: string; + inCommitRepository?: string; +}; + +function buildResolution( + params: ResolutionParams, +): IssueResolutionDetails | undefined { + if ( + params.inCommitRepository !== undefined && + params.inCommit === undefined + ) { + throw new UserInputError("`inCommitRepository` requires `inCommit`"); + } + + if (params.inRelease === undefined && params.inCommit === undefined) { + return undefined; + } + + if (params.status !== "resolved") { + throw new UserInputError( + "`inRelease` and `inCommit` can only be used when `status` is `resolved`", + ); + } + + if (params.inRelease !== undefined && params.inCommit !== undefined) { + throw new UserInputError("Choose only one of `inRelease` or `inCommit`"); + } + + if (params.inRelease !== undefined) { + return { inRelease: params.inRelease }; + } + + if (params.inCommitRepository === undefined) { + throw new UserInputError("`inCommit` requires `inCommitRepository`"); + } + + return { + inCommit: { + commit: params.inCommit!, + repository: params.inCommitRepository, + }, + }; +} + +function isResolutionAlreadySet( + issue: Issue, + resolution: IssueResolutionDetails, +): boolean { + if (issue.status !== "resolved") { + return false; + } + + const statusDetails = getIssueStatusDetails(issue); + + if ("inRelease" in resolution) { + return ( + getStatusDetailString(statusDetails, "inRelease") === resolution.inRelease + ); + } + + return ( + getResolvedCommitId(statusDetails)?.toLowerCase() === + resolution.inCommit.commit.toLowerCase() + ); +} + +function isStatusAlreadySet( + issue: Issue, + status: string | undefined, + resolution: IssueResolutionDetails | undefined, +): boolean { + if (status === undefined || status === "ignored") { + return false; + } + + if (resolution) { + return isResolutionAlreadySet(issue, resolution); + } + + const currentStatus = getIssueStatusDisplay(issue); + + // A plain resolve is satisfied by any resolution except next release, + // which stays pending until a new release exists. + if (status === "resolved") { + return ( + currentStatus === "resolved" || + currentStatus === "resolvedInRelease" || + currentStatus === "resolvedInCommit" + ); + } + + return currentStatus === status; +} + +function getResolvedEffect( + issue: Issue, + statusDisplay: IssueStatusDisplay, +): string | null { + const statusDetails = getIssueStatusDetails(issue); + + switch (statusDisplay) { + case "resolved": + return "The issue is now marked as resolved and will no longer generate alerts"; + case "resolvedInNextRelease": + return "The issue is now marked as resolved in the upcoming release"; + case "resolvedInRelease": + return `The issue is now marked as resolved in release ${getStatusDetailString(statusDetails, "inRelease")}. Events from newer releases will reopen it as a regression`; + case "resolvedInCommit": + return `The issue is now marked as resolved in commit ${getResolvedCommitId(statusDetails)}. It will be resolved in the first release that contains this commit`; + default: + return null; + } +} + function getIgnoreStateFromUpdate( ignoreUpdate?: IgnoreUpdate, ): IgnoreState | null { @@ -713,6 +862,7 @@ export default defineTool({ "- Omit `assignedTo` to leave assignment unchanged. Pass `null` to unassign; otherwise use `user:ID` or `team:ID_OR_SLUG`.", "- Use the `whoami` tool to find your user ID for self-assignment.", "- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.", + "- `status='resolved'` takes optional `inRelease`, or `inCommit` + `inCommitRepository`.", "- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.", "- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.", "- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.", @@ -733,6 +883,9 @@ export default defineTool({ ignoreWindowMinutes: ParamIgnoreWindowMinutes.optional(), ignoreUserCount: ParamIgnoreUserCount.optional(), ignoreUserWindowMinutes: ParamIgnoreUserWindowMinutes.optional(), + inRelease: ParamInRelease.optional(), + inCommit: ParamInCommit.optional(), + inCommitRepository: ParamInCommitRepository.optional(), reason: ParamReason.optional(), }, annotations: { @@ -767,6 +920,8 @@ export default defineTool({ ); } + const resolution = buildResolution(params); + const { organizationSlug: orgSlug, issueId: parsedIssueId } = parseIssueParams({ organizationSlug: params.organizationSlug, @@ -801,10 +956,19 @@ export default defineTool({ currentIgnoreState, ignoreUpdate, ); - const statusAlreadySet = - params.status !== undefined && - params.status !== "ignored" && - getIssueStatusDisplay(currentIssue) === params.status; + const statusAlreadySet = isStatusAlreadySet( + currentIssue, + params.status, + resolution, + ); + + // Sentry leaves an already resolved issue as it is, so a new release or + // commit resolution would silently do nothing. + if (resolution && !statusAlreadySet && currentIssue.status === "resolved") { + throw new UserInputError( + `The issue is already resolved (\`${getIssueStatusDisplay(currentIssue)}\`). To resolve it in a different release or commit, first set \`status\` to \`unresolved\`, then resolve it again.`, + ); + } let updateStatus = statusAlreadySet ? undefined : params.status; const updateAssignedTo = assignmentAlreadySet @@ -865,6 +1029,7 @@ export default defineTool({ organizationSlug: orgSlug, issueId: parsedIssueId!, status: updateStatus, + statusDetails: updateStatus === undefined ? undefined : resolution, assignedTo: updateAssignedTo, substatus: updateIgnore?.substatus, ignoreDuration: updateIgnore?.ignoreDuration, @@ -896,16 +1061,11 @@ export default defineTool({ (statusChanged || ignoreBehaviorChanged) && (ignoreBehaviorChanged || params.status === "ignored"); - let effect: string | null = null; - if (statusChanged && updatedStatusDisplay === "resolved") { - effect = - "The issue is now marked as resolved and will no longer generate alerts"; - } else if ( - statusChanged && - updatedStatusDisplay === "resolvedInNextRelease" - ) { - effect = "The issue is now marked as resolved in the upcoming release"; - } else if ( + let effect = statusChanged + ? getResolvedEffect(updatedIssue, updatedStatusDisplay) + : null; + if ( + effect === null && updatedIgnoreState && params.status === "ignored" && (statusChanged || ignoreBehaviorChanged) diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index f5e900d24..a0d3ea2e6 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -161,6 +161,10 @@ type IssueUpdateBody = { ignoreUserWindow?: number; ignoreWindow?: number; status?: string; + statusDetails?: { + inRelease?: string; + inCommit?: { commit: string; repository: string }; + }; substatus?: string; }; @@ -232,6 +236,16 @@ function buildUpdatedIssueResponse( if (requestedStatus === "resolvedInNextRelease") { statusDetails = { inNextRelease: true }; + } else if (body.statusDetails?.inRelease !== undefined) { + statusDetails = { inRelease: body.statusDetails.inRelease }; + } else if (body.statusDetails?.inCommit !== undefined) { + // Sentry returns the serialized commit, keyed by its SHA. + statusDetails = { + inCommit: { + id: body.statusDetails.inCommit.commit, + repository: { name: body.statusDetails.inCommit.repository }, + }, + }; } else if (status === "ignored") { statusDetails = buildMockIgnoredStatusDetails(body, substatus); } else if (body.status !== undefined) { From 2601a34f71c5f583ae4998948a86eea2f112de21 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:35:02 +0000 Subject: [PATCH 2/4] fix(mcp): Treat resolving an already resolved issue as a no-op Sentry does not change the resolution of an issue that is already resolved, so report updated=false with the current status instead of raising an input error. Co-Authored-By: Bruno Garcia <1633368+bruno-garcia@users.noreply.github.com> --- .../src/tools/catalog/update-issue.test.ts | 32 ++++++++--------- .../src/tools/catalog/update-issue.ts | 34 ++----------------- 2 files changed, 19 insertions(+), 47 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 9de9b22fc..b80c75b3d 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -628,7 +628,7 @@ describe("update_issue", () => { expect(output.current.status).toBe("resolvedInRelease"); }); - it("rejects a release resolution for an issue that is already resolved", async () => { + it("skips a release resolution when the issue is already resolved", async () => { let putCalled = false; mswServer.use( http.get( @@ -647,23 +647,23 @@ describe("update_issue", () => { ), ); - await expect( - updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - inRelease: "backend@1.4.2", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, - ), - ).rejects.toThrow( - "The issue is already resolved (`resolved`). To resolve it in a different release or commit, first set `status` to `unresolved`, then resolve it again.", + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inRelease: "backend@1.4.2", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, ); + expect(putCalled).toBe(false); + const output = structured(result); + expect(output.updated).toBe(false); + expect(output.current.status).toBe("resolved"); }); it("skips a plain resolve when the issue is already resolved in a release", async () => { diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 0a7655a0a..96446c965 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -256,28 +256,6 @@ function buildResolution( }; } -function isResolutionAlreadySet( - issue: Issue, - resolution: IssueResolutionDetails, -): boolean { - if (issue.status !== "resolved") { - return false; - } - - const statusDetails = getIssueStatusDetails(issue); - - if ("inRelease" in resolution) { - return ( - getStatusDetailString(statusDetails, "inRelease") === resolution.inRelease - ); - } - - return ( - getResolvedCommitId(statusDetails)?.toLowerCase() === - resolution.inCommit.commit.toLowerCase() - ); -} - function isStatusAlreadySet( issue: Issue, status: string | undefined, @@ -287,8 +265,10 @@ function isStatusAlreadySet( return false; } + // Sentry leaves an already resolved issue as it is, so resolving it again + // in a release or commit is a no-op that reports the current resolution. if (resolution) { - return isResolutionAlreadySet(issue, resolution); + return issue.status === "resolved"; } const currentStatus = getIssueStatusDisplay(issue); @@ -962,14 +942,6 @@ export default defineTool({ resolution, ); - // Sentry leaves an already resolved issue as it is, so a new release or - // commit resolution would silently do nothing. - if (resolution && !statusAlreadySet && currentIssue.status === "resolved") { - throw new UserInputError( - `The issue is already resolved (\`${getIssueStatusDisplay(currentIssue)}\`). To resolve it in a different release or commit, first set \`status\` to \`unresolved\`, then resolve it again.`, - ); - } - let updateStatus = statusAlreadySet ? undefined : params.status; const updateAssignedTo = assignmentAlreadySet ? undefined From 7dd39df724c38541ec61e64422cff31e76df404b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:19:34 +0000 Subject: [PATCH 3/4] fix(mcp): Reject a different resolution on an already resolved issue Sentry skips groups that are already resolved, so asking update_issue to resolve one in a different release or commit silently did nothing. That includes moving a resolvedInNextRelease issue to the release that shipped. Return an input error that says to unresolve first, as the tool already does for ignore mode switches. A matching resolution is still a no-op. Derive IssueResolutionDetails and the CLI's ResolveStatusDetails from the @sentry/api issue update body instead of hand-writing the shape. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/src/lib/api/issues.ts | 12 ++- packages/mcp-core/src/api-client/types.ts | 14 ++- .../src/tools/catalog/update-issue.test.ts | 85 +++++++++++-------- .../src/tools/catalog/update-issue.ts | 34 +++++++- 4 files changed, 100 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index f47a4db3c..4a5ef50d6 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -7,6 +7,7 @@ import type { GetOrganizationIssueData, ListOrganizationIssuesData, + UpdateOrganizationIssueData, } from "@sentry/api"; import { getOrganizationIssue, @@ -469,6 +470,10 @@ export async function tryGetIssueByShortId( } } +type IssueUpdateStatusDetails = NonNullable< + NonNullable["statusDetails"] +>; + /** * Resolution-release tracking for {@link updateIssueStatus}. Maps to the * `statusDetails` shape expected by Sentry's bulk mutate endpoint: @@ -481,11 +486,14 @@ export async function tryGetIssueByShortId( * Sentry resolves when a release containing the commit is created. Both * `commit` (SHA) and `repository` (Sentry-registered repo name) are * required by the API's InCommitValidator. + * + * Narrowed from the `@sentry/api` issue update body so each variant is + * exclusive. */ export type ResolveStatusDetails = - | { inRelease: string } + | Required> | { inNextRelease: true } - | { inCommit: { commit: string; repository: string } }; + | Required>; /** * Sentinel string meaning "resolve in the next release (tied to HEAD)". diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 38ec7ab84..138129b2a 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -38,6 +38,7 @@ * } * ``` */ +import type { UpdateOrganizationIssueData } from "@sentry/api"; import type { z } from "zod"; import type { AgenticOnboardingRunSchema, @@ -215,13 +216,18 @@ export type Issue = z.infer; export type IssueActivity = z.infer; export type IssueComment = z.infer; +type IssueUpdateStatusDetails = NonNullable< + NonNullable["statusDetails"] +>; + /** - * `statusDetails` that resolve an issue in a specific release or commit. - * Sentry validates both against its own release and commit records. + * `statusDetails` that resolve an issue in a specific release or commit, + * narrowed from the `@sentry/api` issue update body. Sentry validates both + * against its own release and commit records. */ export type IssueResolutionDetails = - | { inRelease: string } - | { inCommit: { commit: string; repository: string } }; + | Required> + | Required>; export type Monitor = z.infer; export type MonitorCheckIn = z.infer; export type MonitorStat = z.infer; diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index b80c75b3d..11e07c499 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -628,43 +628,56 @@ describe("update_issue", () => { expect(output.current.status).toBe("resolvedInRelease"); }); - it("skips a release resolution when the issue is already resolved", async () => { - let putCalled = false; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", - () => - HttpResponse.json( - createIssue({ status: "resolved", statusDetails: {} }), - ), - ), - http.put( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", - () => { - putCalled = true; - return HttpResponse.json(createIssue()); - }, - ), - ); - - const result = await updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - inRelease: "backend@1.4.2", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, - ); + it.each([ + { current: "resolved", statusDetails: {} }, + { + current: "resolvedInNextRelease", + statusDetails: { inNextRelease: true }, + }, + { + current: "resolvedInRelease", + statusDetails: { inRelease: "backend@1.4.1" }, + }, + ])( + "rejects a release resolution for an issue that is already $current", + async ({ current, statusDetails }) => { + let putCalled = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => + HttpResponse.json( + createIssue({ status: "resolved", statusDetails }), + ), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + putCalled = true; + return HttpResponse.json(createIssue()); + }, + ), + ); - expect(putCalled).toBe(false); - const output = structured(result); - expect(output.updated).toBe(false); - expect(output.current.status).toBe("resolved"); - }); + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inRelease: "backend@1.4.2", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), + ).rejects.toThrow( + `The issue is already resolved (\`${current}\`). To resolve it in a different release or commit, first set \`status\` to \`unresolved\`, then resolve it again.`, + ); + expect(putCalled).toBe(false); + }, + ); it("skips a plain resolve when the issue is already resolved in a release", async () => { let putCalled = false; diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 96446c965..ba374d665 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -256,6 +256,28 @@ function buildResolution( }; } +function isResolutionAlreadySet( + issue: Issue, + resolution: IssueResolutionDetails, +): boolean { + if (issue.status !== "resolved") { + return false; + } + + const statusDetails = getIssueStatusDetails(issue); + + if ("inRelease" in resolution) { + return ( + getStatusDetailString(statusDetails, "inRelease") === resolution.inRelease + ); + } + + return ( + getResolvedCommitId(statusDetails)?.toLowerCase() === + resolution.inCommit.commit.toLowerCase() + ); +} + function isStatusAlreadySet( issue: Issue, status: string | undefined, @@ -265,10 +287,8 @@ function isStatusAlreadySet( return false; } - // Sentry leaves an already resolved issue as it is, so resolving it again - // in a release or commit is a no-op that reports the current resolution. if (resolution) { - return issue.status === "resolved"; + return isResolutionAlreadySet(issue, resolution); } const currentStatus = getIssueStatusDisplay(issue); @@ -942,6 +962,14 @@ export default defineTool({ resolution, ); + // Sentry leaves an already resolved issue as it is, so a different release + // or commit resolution would silently do nothing. + if (resolution && !statusAlreadySet && currentIssue.status === "resolved") { + throw new UserInputError( + `The issue is already resolved (\`${getIssueStatusDisplay(currentIssue)}\`). To resolve it in a different release or commit, first set \`status\` to \`unresolved\`, then resolve it again.`, + ); + } + let updateStatus = statusAlreadySet ? undefined : params.status; const updateAssignedTo = assignmentAlreadySet ? undefined From 7acb4bb6f800d0bce9a6f0c6611f80cd97856db4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:29:34 +0000 Subject: [PATCH 4/4] fix(mcp): Match a commit retry against the latest resolve activity Once a release contains the resolving commit, Sentry reports the issue as resolved in that release and the issue details drop the commit. A retry of the same inCommit then looked like a different resolution and update_issue threw. For an inCommit request on an issue resolved in a release, read the newest resolve activity and treat the request as already applied when it is set_resolved_in_commit for the same SHA. --- .../src/tools/catalog/update-issue.test.ts | 74 +++++++++++++++++++ .../src/tools/catalog/update-issue.ts | 52 ++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 11e07c499..c9c6e3af5 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -587,6 +587,80 @@ describe("update_issue", () => { ); }); + it.each([ + { + name: "skips a commit resolution that Sentry reports as its release", + activityCommit: "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", + rejects: false, + }, + { + name: "rejects a different commit on an issue resolved in a release", + activityCommit: "ffffffffffffffffffffffffffffffffffffffff", + rejects: true, + }, + ])("$name", async ({ activityCommit, rejects }) => { + let putCalled = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => + HttpResponse.json( + createIssue({ + status: "resolved", + statusDetails: { inRelease: "backend@1.4.2" }, + }), + ), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/activities/", + () => + HttpResponse.json({ + activity: [ + { id: "3", type: "note", data: { text: "shipped" } }, + { + id: "2", + type: "set_resolved_in_commit", + data: { commit: { id: activityCommit } }, + }, + { id: "1", type: "set_resolved", data: {} }, + ], + }), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + putCalled = true; + return HttpResponse.json(createIssue()); + }, + ), + ); + + const run = updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + inCommit: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + inCommitRepository: "my-org/backend", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + if (rejects) { + await expect(run).rejects.toThrow( + "The issue is already resolved (`resolvedInRelease`)", + ); + } else { + const output = structured(await run); + expect(output.updated).toBe(false); + expect(output.current.status).toBe("resolvedInRelease"); + } + expect(putCalled).toBe(false); + }); + it("skips updates when the issue is already resolved in the requested release", async () => { let putCalled = false; mswServer.use( diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index ba374d665..951324b5f 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { AssignedTo, Issue, + IssueActivityList, IssueResolutionDetails, } from "../../api-client/types"; import { UserInputError } from "../../errors"; @@ -170,8 +171,9 @@ function getStatusDetailString( /** SHA of the commit an issue is resolved in, from the serialized commit. */ function getResolvedCommitId( statusDetails: Record, + key: "inCommit" | "commit" = "inCommit", ): string | undefined { - const commit = statusDetails.inCommit; + const commit = statusDetails[key]; if (commit && typeof commit === "object" && !Array.isArray(commit)) { const id = (commit as Record).id; if (typeof id === "string" && id.length > 0) { @@ -278,6 +280,39 @@ function isResolutionAlreadySet( ); } +/** + * Sentry reports a commit resolution as `inRelease` once a release contains + * the commit, so the issue details no longer name the commit. The newest + * resolve activity still does. + */ +async function isLatestResolutionCommit( + apiService: { + getIssueActivity: (params: { + organizationSlug: string; + issueId: string; + }) => Promise; + }, + organizationSlug: string, + issueId: string, + commit: string, +): Promise { + const activity = await apiService.getIssueActivity({ + organizationSlug, + issueId, + }); + const latestResolution = activity.find((item) => + item.type?.startsWith("set_resolved"), + ); + + return ( + latestResolution?.type === "set_resolved_in_commit" && + getResolvedCommitId( + latestResolution.data ?? {}, + "commit", + )?.toLowerCase() === commit.toLowerCase() + ); +} + function isStatusAlreadySet( issue: Issue, status: string | undefined, @@ -956,11 +991,24 @@ export default defineTool({ currentIgnoreState, ignoreUpdate, ); - const statusAlreadySet = isStatusAlreadySet( + let statusAlreadySet = isStatusAlreadySet( currentIssue, params.status, resolution, ); + if ( + !statusAlreadySet && + resolution && + "inCommit" in resolution && + getIssueStatusDisplay(currentIssue) === "resolvedInRelease" + ) { + statusAlreadySet = await isLatestResolutionCommit( + apiService, + orgSlug, + parsedIssueId!, + resolution.inCommit.commit, + ); + } // Sentry leaves an already resolved issue as it is, so a different release // or commit resolution would silently do nothing.