Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eb46060
feat(issue): Link external issues
dcramer May 29, 2026
24a48f8
fix(update-issue): post reason comment in partial-success link failur…
sentry-junior[bot] May 29, 2026
c4f83f5
fix(issue): Match Azure DevOps organization domains
dcramer May 29, 2026
02f29d6
fix(issue): Normalize www integration domains
dcramer May 29, 2026
6b45140
fix(issue): Mark partial link failures as errors
dcramer May 29, 2026
a6f4e16
fix(issue): Support Jira Server domains with ports
dcramer May 29, 2026
43b8684
fix(issue): Mark error tool result spans as failed
dcramer May 29, 2026
45d036d
fix(issue-linking): guard against non-GitHub hosts and stray Bitbucke…
sentry-junior[bot] May 29, 2026
1133c02
fix(issue): Avoid retry signal for partial update success
dcramer May 29, 2026
fc49666
test(issue): Remove unused error result helper
dcramer May 29, 2026
6897291
fix(issue): Report comment-only updates clearly
dcramer May 29, 2026
c805149
fix(issue-linking): use first match for multiple candidates; exclude …
sentry-junior[bot] May 30, 2026
c1f5416
fix(issue-linking): remove dead code, improve domain-mismatch errors
sentry-junior[bot] May 30, 2026
be76a1e
ref: Clean up issue linking helpers
dcramer May 30, 2026
3851b43
fix(issue): Mark partial link failures as errors
dcramer May 30, 2026
d6fd884
fix(update-issue): drop isError from partial-success link failure path
sentry-junior[bot] Jun 5, 2026
3f87723
fix(issue-linking): Address rebase review findings
dcramer Jun 16, 2026
651f006
feat(issue): add catalog link and unlink operations
betegon Sep 28, 2026
1ba04d2
refactor(issue): deduplicate linking helpers and tests
betegon Sep 28, 2026
e56954e
fix(issue): align Jira URLs and optional App fields
betegon Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ server. Each spec should live in a single Markdown file under `docs/specs/`.
- [Agent Conversations](ai-conversations.md)
- [Alert Inspection and Editing](alert-rules.md)
- [Embedded Agent OpenAI Routing](embedded-agent-openai-routing.md)
- [External Issue Linking](issue-linking.md)
- [Project Management Tools](project-management.md)
- [Remembered OAuth Skill Defaults](remembered-oauth-skills.md)
- [Search Events](search-events.md)
Expand Down
75 changes: 75 additions & 0 deletions docs/specs/issue-linking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# External Issue Linking

`link_issue` and `unlink_issue` manage references between an existing Sentry issue
and an existing external ticket or GitHub pull request. They are catalog-only
tools, discovered through `search_sentry_tools` and called through
`execute_sentry_tool`. Both require the `triage` skill and `event:write` and
`org:read` scopes. No `event:admin` grant is added.

## Interface

```ts
execute_sentry_tool({
name: "link_issue",
arguments: {
organizationSlug: "my-org",
issueId: "PROJECT-123",
externalIssueUrl: "https://github.com/example/repo/pull/42",
},
});
```

Use `unlink_issue` with the same arguments to remove the association. Either
tool accepts `issueUrl` instead of `organizationSlug` and `issueId`, and an
optional `regionUrl`. Session organization, project, and region constraints
still apply. The source issue is resolved before any mutation; subsequent API
calls use its numeric ID.

Native integrations are selected by the URL and installed integration metadata.
An optional `integrationId` disambiguates multiple matching installations. Sentry
receives the complete URL and performs provider-specific parsing and validation
for Jira, GitHub/GitHub Enterprise, GitLab, Bitbucket, and Azure DevOps.

For Sentry Apps, `appSlug` selects the installed App; Linear and Shortcut are
inferred from their URLs. `link_issue` accepts optional `fields` for additional
values required by the App's issue-link form. The client reads the installed
component and resolves its form choices before invoking its link callback.
Missing or ambiguous fields are reported without submitting the callback.
Supported fields are single-value selects, text, and textarea. Other field types
and multi-select fields are reported as unsupported.

## Outcomes and retries

Results contain the Sentry issue ID and URL, the external reference, and a status:

- `linked`: the backend created the association (HTTP 201).
- `already_linked`: the backend returned the existing association (HTTP 200).
- `not_linked`: the association is absent after unlink. This does not claim
which concurrent request removed it.

App requests include `expectedExternalIssueUrl` as a query parameter. The URL
must exactly match the canonical `webUrl` returned by the App. Copy the URL from
the provider; a different title suffix or URL alias is not necessarily accepted.
An existing equivalent reference uses its stored URL for the guard. A different
App association must be explicitly unlinked first; no direct-registration or
unguarded fallback is used. HTTP 409 errors propagate without automatic retries.
If an App callback returns a conflicting URL, its external effects cannot be
rolled back, even though Sentry rejects the association.

Unlink first finds the association by URL, then deletes using its internal Sentry
ID. It never deletes the external ticket or the Sentry issue. Repeating unlink
when no association exists returns `not_linked`.
App deletion is conditional on the association ID, not its URL. A legacy App
writer can replace the URL while retaining that ID between lookup and deletion;
preventing that race would require a conditional-delete API in Sentry.

## Boundaries

GitHub pull requests are external references here. These tools do not create
tickets, link commits, resolve issues, or change assignment. `update_issue`
continues to handle status and assignment separately.

The implementation uses the existing MCP API client, without `@sentry/api`.
It requires the backend's URL linking and guarded App action behavior, including
the HTTP 200/201 contract from getsentry/sentry#124069. Older self-hosted releases
may lack these capabilities; the client does not emulate the missing guarantees.
172 changes: 172 additions & 0 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,178 @@ describe("getTraceUrl", () => {
});
});

describe("external issue linking API methods", () => {
const organizationSlug = "test-org";
const issueId = "123";
const integrationId = "456";
const externalIssueUrl = "https://github.com/example/project/issues/42";
const nativeIssue = {
id: 789,
key: "example/project#42",
url: externalIssueUrl,
};
const appIssue = {
id: "789",
issueId,
serviceType: "linear",
displayName: "ENG-42",
webUrl: "https://linear.app/example/issue/ENG-42/title",
};
const api = new SentryApiService({
host: "us.sentry.io",
accessToken: "test-token",
});

it("reads every integration page and preserves internal link IDs and provider metadata", async () => {
const integration = {
id: integrationId,
name: "example",
domainName: "github.com/example",
status: "active",
provider: { key: "github" },
externalIssues: [nativeIssue],
};
const pages: (string | null)[] = [];
mswServer.use(
http.get(
"https://us.sentry.io/api/0/organizations/test-org/issues/123/integrations/",
({ request }) => {
const cursor = new URL(request.url).searchParams.get("cursor");
pages.push(cursor);
return HttpResponse.json(
[{ ...integration, id: cursor ? 457 : integrationId }],
{
headers: cursor
? {}
: {
Link: '<https://us.sentry.io/>; rel="next"; results="true"; cursor="next-page"',
},
},
);
},
),
);
expect(
await api.listIssueIntegrations({ organizationSlug, issueId }),
).toEqual([integration, { ...integration, id: 457 }]);
expect(pages).toEqual([null, "next-page"]);
});

it("finds App associations beyond the first page", async () => {
mswServer.use(
http.get(
"https://us.sentry.io/api/0/organizations/test-org/issues/123/external-issues/",
({ request }) => {
const cursor = new URL(request.url).searchParams.get("cursor");
return HttpResponse.json(cursor ? [appIssue] : [], {
headers: cursor
? {}
: {
Link: '<https://us.sentry.io/>; rel="next"; results="true"; cursor="next-page"',
},
});
},
),
);
expect(
await api.getIssueExternalLinks({ organizationSlug, issueId }),
).toEqual([appIssue]);
});

it("loads App installations and paginated issue-link forms on the control host", async () => {
const installation = {
uuid: "install-uuid",
status: "installed",
app: { slug: "linear", uuid: "app-uuid" },
};
const component = {
type: "issue-link",
sentryApp: { slug: "linear", uuid: "app-uuid" },
schema: { link: { uri: "/link" } },
};
const pages: (string | null)[] = [];
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/sentry-app-installations/",
() => HttpResponse.json([installation]),
),
http.get(
"https://sentry.io/api/0/organizations/test-org/sentry-app-components/",
({ request }) => {
const query = new URL(request.url).searchParams;
expect(query.get("filter")).toBe("issue-link");
pages.push(query.get("cursor"));
return HttpResponse.json(query.has("cursor") ? [component] : [], {
headers: query.has("cursor")
? {}
: {
Link: '<https://sentry.io/>; rel="next"; results="true"; cursor="next-page"',
},
});
},
),
);
expect(await api.listSentryAppInstallations({ organizationSlug })).toEqual([
installation,
]);
expect(await api.listSentryAppComponents({ organizationSlug })).toEqual([
component,
]);
expect(pages).toEqual([null, "next-page"]);
});

it.each(["tenant.my.sentry.io", "sentry.example.com"])(
"keeps App choices on %s and encodes search dependencies",
async (host) => {
const tenantApi = new SentryApiService({ host });
mswServer.use(
http.get(
`https://${host}/api/0/sentry-app-installations/install-uuid/external-requests/`,
({ request }) => {
expect(
Object.fromEntries(new URL(request.url).searchParams),
).toEqual({
uri: "/search",
projectId: "42",
query: "ENG-42",
dependentData: JSON.stringify({ team: "ENG" }),
});
return HttpResponse.json({ choices: [["ticket-uuid", "ENG-42"]] });
},
),
);
expect(
await tenantApi.getSentryAppExternalRequestOptions({
installationUuid: "install-uuid",
uri: "/search",
query: "ENG-42",
projectId: "42",
dependentData: { team: "ENG" },
}),
).toEqual({ choices: [["ticket-uuid", "ENG-42"]] });
},
);

it("unlinks an App by internal association ID using the regional endpoint", async () => {
const requests: string[] = [];
mswServer.use(
http.delete(
"https://us.sentry.io/api/0/organizations/test-org/issues/123/external-issues/789/",
() => {
requests.push("app");
return new HttpResponse(null, { status: 204 });
},
),
);
await api.unlinkSentryAppExternalIssue({
organizationSlug,
issueId,
externalIssueId: "789",
});
expect(requests).toEqual(["app"]);
});
});

describe("getEventsExplorerUrl", () => {
it("should work with sentry.io", () => {
const apiService = new SentryApiService({ host: "sentry.io" });
Expand Down
Loading
Loading