Skip to content

Commit 0563bde

Browse files
dcramercodexsentry-junior[bot]betegon
authored
feat(issue): link and unlink external issues (#1027)
Add catalog-only `link_issue` and `unlink_issue` so agents can associate existing external tickets or GitHub pull requests with a Sentry issue, then remove those references by URL. Both use the existing MCP API client; this does not depend on `@sentry/api` or change `update_issue`. Native integrations receive the full URL for backend parsing. Sentry Apps use their installed issue-link form and guarded callback, so retries return the existing association and a different App link must be explicitly unlinked first. Both tools require the `triage` skill with `event:write` and `org:read`, preserving organization, project, and region constraints. This requires the backend URL-linking changes and the retry contract from getsentry/sentry#124069. App support covers single-value select/text forms and requires the provider's exact canonical URL. Unlink uses the existing association-ID DELETE contract; it does not delete the external ticket or resolve the Sentry issue. Fixes #228 Related: #1257 tracks creating new external tickets, and #1095 covers richer integration metadata. Those capabilities are outside this PR; only #228 is resolved here. --------- Co-authored-by: GPT-5 Codex <codex@openai.com> Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: betegon <miguelbetegongarcia@gmail.com> Co-authored-by: Codex (GPT-6) <noreply@openai.com>
1 parent 6494584 commit 0563bde

20 files changed

Lines changed: 3064 additions & 8 deletions

‎docs/specs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ server. Each spec should live in a single Markdown file under `docs/specs/`.
88
- [Agent Conversations](ai-conversations.md)
99
- [Alert Inspection and Editing](alert-rules.md)
1010
- [Embedded Agent OpenAI Routing](embedded-agent-openai-routing.md)
11+
- [External Issue Linking](issue-linking.md)
1112
- [Project Management Tools](project-management.md)
1213
- [Remembered OAuth Skill Defaults](remembered-oauth-skills.md)
1314
- [Search Events](search-events.md)

‎docs/specs/issue-linking.md‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# External Issue Linking
2+
3+
`link_issue` and `unlink_issue` manage references between an existing Sentry issue
4+
and an existing external ticket or GitHub pull request. They are catalog-only
5+
tools, discovered through `search_sentry_tools` and called through
6+
`execute_sentry_tool`. Both require the `triage` skill and `event:write` and
7+
`org:read` scopes. No `event:admin` grant is added.
8+
9+
## Interface
10+
11+
```ts
12+
execute_sentry_tool({
13+
name: "link_issue",
14+
arguments: {
15+
organizationSlug: "my-org",
16+
issueId: "PROJECT-123",
17+
externalIssueUrl: "https://github.com/example/repo/pull/42",
18+
},
19+
});
20+
```
21+
22+
Use `unlink_issue` with the same arguments to remove the association. Either
23+
tool accepts `issueUrl` instead of `organizationSlug` and `issueId`, and an
24+
optional `regionUrl`. Session organization, project, and region constraints
25+
still apply. The source issue is resolved before any mutation; subsequent API
26+
calls use its numeric ID.
27+
28+
Native integrations are selected by the URL and installed integration metadata.
29+
An optional `integrationId` disambiguates multiple matching installations. Sentry
30+
receives the complete URL and performs provider-specific parsing and validation
31+
for Jira, GitHub/GitHub Enterprise, GitLab, Bitbucket, and Azure DevOps.
32+
33+
For Sentry Apps, `appSlug` selects the installed App; Linear and Shortcut are
34+
inferred from their URLs. `link_issue` accepts optional `fields` for additional
35+
values required by the App's issue-link form. The client reads the installed
36+
component and resolves its form choices before invoking its link callback.
37+
Missing or ambiguous fields are reported without submitting the callback.
38+
Supported fields are single-value selects, text, and textarea. Other field types
39+
and multi-select fields are reported as unsupported.
40+
41+
## Outcomes and retries
42+
43+
Results contain the Sentry issue ID and URL, the external reference, and a status:
44+
45+
- `linked`: the backend created the association (HTTP 201).
46+
- `already_linked`: the backend returned the existing association (HTTP 200).
47+
- `not_linked`: the association is absent after unlink. This does not claim
48+
which concurrent request removed it.
49+
50+
App requests include `expectedExternalIssueUrl` as a query parameter. The URL
51+
must exactly match the canonical `webUrl` returned by the App. Copy the URL from
52+
the provider; a different title suffix or URL alias is not necessarily accepted.
53+
An existing equivalent reference uses its stored URL for the guard. A different
54+
App association must be explicitly unlinked first; no direct-registration or
55+
unguarded fallback is used. HTTP 409 errors propagate without automatic retries.
56+
If an App callback returns a conflicting URL, its external effects cannot be
57+
rolled back, even though Sentry rejects the association.
58+
59+
Unlink first finds the association by URL, then deletes using its internal Sentry
60+
ID. It never deletes the external ticket or the Sentry issue. Repeating unlink
61+
when no association exists returns `not_linked`.
62+
App deletion is conditional on the association ID, not its URL. A legacy App
63+
writer can replace the URL while retaining that ID between lookup and deletion;
64+
preventing that race would require a conditional-delete API in Sentry.
65+
66+
## Boundaries
67+
68+
GitHub pull requests are external references here. These tools do not create
69+
tickets, link commits, resolve issues, or change assignment. `update_issue`
70+
continues to handle status and assignment separately.
71+
72+
The implementation uses the existing MCP API client, without `@sentry/api`.
73+
It requires the backend's URL linking and guarded App action behavior, including
74+
the HTTP 200/201 contract from getsentry/sentry#124069. Older self-hosted releases
75+
may lack these capabilities; the client does not emulate the missing guarantees.

‎packages/mcp-core/src/api-client/client.test.ts‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,178 @@ describe("getTraceUrl", () => {
197197
});
198198
});
199199

200+
describe("external issue linking API methods", () => {
201+
const organizationSlug = "test-org";
202+
const issueId = "123";
203+
const integrationId = "456";
204+
const externalIssueUrl = "https://github.com/example/project/issues/42";
205+
const nativeIssue = {
206+
id: 789,
207+
key: "example/project#42",
208+
url: externalIssueUrl,
209+
};
210+
const appIssue = {
211+
id: "789",
212+
issueId,
213+
serviceType: "linear",
214+
displayName: "ENG-42",
215+
webUrl: "https://linear.app/example/issue/ENG-42/title",
216+
};
217+
const api = new SentryApiService({
218+
host: "us.sentry.io",
219+
accessToken: "test-token",
220+
});
221+
222+
it("reads every integration page and preserves internal link IDs and provider metadata", async () => {
223+
const integration = {
224+
id: integrationId,
225+
name: "example",
226+
domainName: "github.com/example",
227+
status: "active",
228+
provider: { key: "github" },
229+
externalIssues: [nativeIssue],
230+
};
231+
const pages: (string | null)[] = [];
232+
mswServer.use(
233+
http.get(
234+
"https://us.sentry.io/api/0/organizations/test-org/issues/123/integrations/",
235+
({ request }) => {
236+
const cursor = new URL(request.url).searchParams.get("cursor");
237+
pages.push(cursor);
238+
return HttpResponse.json(
239+
[{ ...integration, id: cursor ? 457 : integrationId }],
240+
{
241+
headers: cursor
242+
? {}
243+
: {
244+
Link: '<https://us.sentry.io/>; rel="next"; results="true"; cursor="next-page"',
245+
},
246+
},
247+
);
248+
},
249+
),
250+
);
251+
expect(
252+
await api.listIssueIntegrations({ organizationSlug, issueId }),
253+
).toEqual([integration, { ...integration, id: 457 }]);
254+
expect(pages).toEqual([null, "next-page"]);
255+
});
256+
257+
it("finds App associations beyond the first page", async () => {
258+
mswServer.use(
259+
http.get(
260+
"https://us.sentry.io/api/0/organizations/test-org/issues/123/external-issues/",
261+
({ request }) => {
262+
const cursor = new URL(request.url).searchParams.get("cursor");
263+
return HttpResponse.json(cursor ? [appIssue] : [], {
264+
headers: cursor
265+
? {}
266+
: {
267+
Link: '<https://us.sentry.io/>; rel="next"; results="true"; cursor="next-page"',
268+
},
269+
});
270+
},
271+
),
272+
);
273+
expect(
274+
await api.getIssueExternalLinks({ organizationSlug, issueId }),
275+
).toEqual([appIssue]);
276+
});
277+
278+
it("loads App installations and paginated issue-link forms on the control host", async () => {
279+
const installation = {
280+
uuid: "install-uuid",
281+
status: "installed",
282+
app: { slug: "linear", uuid: "app-uuid" },
283+
};
284+
const component = {
285+
type: "issue-link",
286+
sentryApp: { slug: "linear", uuid: "app-uuid" },
287+
schema: { link: { uri: "/link" } },
288+
};
289+
const pages: (string | null)[] = [];
290+
mswServer.use(
291+
http.get(
292+
"https://sentry.io/api/0/organizations/test-org/sentry-app-installations/",
293+
() => HttpResponse.json([installation]),
294+
),
295+
http.get(
296+
"https://sentry.io/api/0/organizations/test-org/sentry-app-components/",
297+
({ request }) => {
298+
const query = new URL(request.url).searchParams;
299+
expect(query.get("filter")).toBe("issue-link");
300+
pages.push(query.get("cursor"));
301+
return HttpResponse.json(query.has("cursor") ? [component] : [], {
302+
headers: query.has("cursor")
303+
? {}
304+
: {
305+
Link: '<https://sentry.io/>; rel="next"; results="true"; cursor="next-page"',
306+
},
307+
});
308+
},
309+
),
310+
);
311+
expect(await api.listSentryAppInstallations({ organizationSlug })).toEqual([
312+
installation,
313+
]);
314+
expect(await api.listSentryAppComponents({ organizationSlug })).toEqual([
315+
component,
316+
]);
317+
expect(pages).toEqual([null, "next-page"]);
318+
});
319+
320+
it.each(["tenant.my.sentry.io", "sentry.example.com"])(
321+
"keeps App choices on %s and encodes search dependencies",
322+
async (host) => {
323+
const tenantApi = new SentryApiService({ host });
324+
mswServer.use(
325+
http.get(
326+
`https://${host}/api/0/sentry-app-installations/install-uuid/external-requests/`,
327+
({ request }) => {
328+
expect(
329+
Object.fromEntries(new URL(request.url).searchParams),
330+
).toEqual({
331+
uri: "/search",
332+
projectId: "42",
333+
query: "ENG-42",
334+
dependentData: JSON.stringify({ team: "ENG" }),
335+
});
336+
return HttpResponse.json({ choices: [["ticket-uuid", "ENG-42"]] });
337+
},
338+
),
339+
);
340+
expect(
341+
await tenantApi.getSentryAppExternalRequestOptions({
342+
installationUuid: "install-uuid",
343+
uri: "/search",
344+
query: "ENG-42",
345+
projectId: "42",
346+
dependentData: { team: "ENG" },
347+
}),
348+
).toEqual({ choices: [["ticket-uuid", "ENG-42"]] });
349+
},
350+
);
351+
352+
it("unlinks an App by internal association ID using the regional endpoint", async () => {
353+
const requests: string[] = [];
354+
mswServer.use(
355+
http.delete(
356+
"https://us.sentry.io/api/0/organizations/test-org/issues/123/external-issues/789/",
357+
() => {
358+
requests.push("app");
359+
return new HttpResponse(null, { status: 204 });
360+
},
361+
),
362+
);
363+
await api.unlinkSentryAppExternalIssue({
364+
organizationSlug,
365+
issueId,
366+
externalIssueId: "789",
367+
});
368+
expect(requests).toEqual(["app"]);
369+
});
370+
});
371+
200372
describe("getEventsExplorerUrl", () => {
201373
it("should work with sentry.io", () => {
202374
const apiService = new SentryApiService({ host: "sentry.io" });

0 commit comments

Comments
 (0)