Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 38 additions & 0 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
IssueListSchema,
IssueSchema,
IssueTagValuesSchema,
ExternalIssueListSchema,
EventSchema,
EventAttachmentListSchema,
ErrorsSearchResponseSchema,
Expand Down Expand Up @@ -44,6 +45,7 @@ import type {
Issue,
IssueList,
IssueTagValues,
ExternalIssueList,
OrganizationList,
Project,
ProjectList,
Expand Down Expand Up @@ -1593,6 +1595,42 @@ export class SentryApiService {
return IssueTagValuesSchema.parse(body);
}

/**
* Retrieves external issue links for a specific issue.
*
* Returns a list of external issue tracking links (e.g., Jira, GitHub Issues)
* that are connected to the given Sentry issue.
*
* @param params - Parameters including organization slug and issue ID
* @param opts - Optional request options
* @returns Promise resolving to list of external issue links
*
* @example
* ```typescript
* const externalIssues = await apiService.getIssueExternalLinks({
* organizationSlug: "my-org",
* issueId: "PROJECT-123",
* });
* ```
*/
async getIssueExternalLinks(
{
organizationSlug,
issueId,
}: {
organizationSlug: string;
issueId: string;
},
opts?: RequestOptions,
): Promise<ExternalIssueList> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/external-issues/`,
undefined,
opts,
);
return ExternalIssueListSchema.parse(body);
}

async getEventForIssue(
{
organizationSlug,
Expand Down
16 changes: 16 additions & 0 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,22 @@ export const IssueTagValuesSchema = z.object({
topValues: z.array(IssueTagValueSchema),
});

/**
* Schema for external issue link (e.g., Jira, GitHub Issues).
*
* Represents a link between a Sentry issue and an external issue tracking
* system like Jira, GitHub Issues, GitLab, etc.
*/
export const ExternalIssueSchema = z.object({
id: z.string(),
issueId: z.string(),
serviceType: z.string(),
displayName: z.string(),
webUrl: z.string(),
});

export const ExternalIssueListSchema = z.array(ExternalIssueSchema);

/**
* Schema for Sentry trace metadata response.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp-core/src/api-client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import type {
IssueListSchema,
IssueSchema,
IssueTagValuesSchema,
ExternalIssueSchema,
ExternalIssueListSchema,
OrganizationListSchema,
OrganizationSchema,
ProjectListSchema,
Expand Down Expand Up @@ -120,3 +122,7 @@ export type Trace = z.infer<typeof TraceSchema>;

// Issue tag values
export type IssueTagValues = z.infer<typeof IssueTagValuesSchema>;

// External issue links (Jira, GitHub, etc.)
export type ExternalIssue = z.infer<typeof ExternalIssueSchema>;
export type ExternalIssueList = z.infer<typeof ExternalIssueListSchema>;
185 changes: 185 additions & 0 deletions packages/mcp-core/src/tools/get-issue-external-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it } from "vitest";
import getIssueExternalLinks from "./get-issue-external-links";
import { getServerContext } from "../test-helpers/server";

describe("get-issue-external-links tool", () => {
beforeEach(() => {
getServerContext().reset();
});

describe("successful calls", () => {
it("should fetch external issue links for an issue", async () => {
const mockExternalIssues = [
{
id: "123",
issueId: "456",
serviceType: "jira",
displayName: "AMP-12345",
webUrl: "https://amplitude.atlassian.net/browse/AMP-12345",
},
{
id: "124",
issueId: "456",
serviceType: "github",
displayName: "getsentry/sentry#12345",
webUrl: "https://github.com/getsentry/sentry/issues/12345",
},
];

const server = getServerContext();
server.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/issues/PROJECT-123/external-issues/",
() => {
return HttpResponse.json(mockExternalIssues);
},
),
);

const result = await getIssueExternalLinks.handler(
{
organizationSlug: "test-org",
issueId: "PROJECT-123",
regionUrl: null,
},
server.context,
);

expect(result).toContain("# External Issue Links for PROJECT-123");
expect(result).toContain("Found 2 external issue link(s)");
expect(result).toContain("## AMP-12345");
expect(result).toContain("**Type**: jira");
expect(result).toContain(
"https://amplitude.atlassian.net/browse/AMP-12345",
);
expect(result).toContain("## getsentry/sentry#12345");
expect(result).toContain("**Type**: github");
expect(result).toContain(
"https://github.com/getsentry/sentry/issues/12345",
);
});

it("should return message when no external issues are linked", async () => {
const server = getServerContext();
server.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/issues/PROJECT-456/external-issues/",
() => {
return HttpResponse.json([]);
},
),
);

const result = await getIssueExternalLinks.handler(
{
organizationSlug: "test-org",
issueId: "PROJECT-456",
regionUrl: null,
},
server.context,
);

expect(result).toContain("# No External Issues Found");
expect(result).toContain(
"No external issue tracking links (Jira, GitHub, etc.)",
);
expect(result).toContain("**Issue ID**: PROJECT-456");
expect(result).toContain("**Organization**: test-org");
});
});

describe("issueUrl parameter", () => {
it("should extract organization and issue ID from Sentry URL", async () => {
const mockExternalIssues = [
{
id: "789",
issueId: "999",
serviceType: "jira",
displayName: "DASH-1Q3H",
webUrl: "https://amplitude.atlassian.net/browse/DASH-1Q3H",
},
];

const server = getServerContext();
server.use(
http.get(
"https://sentry.io/api/0/organizations/my-org/issues/ISSUE-789/external-issues/",
() => {
return HttpResponse.json(mockExternalIssues);
},
),
);

const result = await getIssueExternalLinks.handler(
{
issueUrl: "https://my-org.sentry.io/issues/ISSUE-789/",
regionUrl: null,
},
server.context,
);

expect(result).toContain("# External Issue Links for ISSUE-789");
expect(result).toContain("## DASH-1Q3H");
expect(result).toContain("**Type**: jira");
});
});

describe("error handling", () => {
it("should throw error when neither issueId nor issueUrl is provided", async () => {
const server = getServerContext();

await expect(
getIssueExternalLinks.handler(
{
organizationSlug: "test-org",
regionUrl: null,
},
server.context,
),
).rejects.toThrow("Either `issueId` or `issueUrl` must be provided");
});

it("should throw error when organizationSlug is missing with issueId", async () => {
const server = getServerContext();

await expect(
getIssueExternalLinks.handler(
{
issueId: "PROJECT-123",
regionUrl: null,
},
server.context,
),
).rejects.toThrow(
"`organizationSlug` is required when providing `issueId`",
);
});

it("should handle 404 errors gracefully", async () => {
const server = getServerContext();
server.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/issues/NONEXISTENT/external-issues/",
() => {
return HttpResponse.json(
{ detail: "The requested resource does not exist" },
{ status: 404 },
);
},
),
);

await expect(
getIssueExternalLinks.handler(
{
organizationSlug: "test-org",
issueId: "NONEXISTENT",
regionUrl: null,
},
server.context,
),
).rejects.toThrow();
});
});
});
Loading