diff --git a/packages/cloud-agents/src/server/router/__tests__/external-issue-providers.test.ts b/packages/cloud-agents/src/server/router/__tests__/external-issue-providers.test.ts new file mode 100644 index 00000000..598934a6 --- /dev/null +++ b/packages/cloud-agents/src/server/router/__tests__/external-issue-providers.test.ts @@ -0,0 +1,76 @@ +import { + matchExternalIssueUrl, + type ExternalIssueProvider, +} from '../external-issue-providers'; + +describe('matchExternalIssueUrl', () => { + it('maps a GitHub issue URL to issue_read with a get_issue fallback', () => { + const match = matchExternalIssueUrl( + new URL('https://github.com/acme/web/issues/42'), + ); + + expect(match?.fetchAttempts).toEqual([ + { + serverId: 'github', + toolName: 'issue_read', + args: { method: 'get', owner: 'acme', repo: 'web', issue_number: 42 }, + }, + { + serverId: 'github', + toolName: 'get_issue', + args: { owner: 'acme', repo: 'web', issue_number: 42 }, + }, + ]); + }); + + it('maps a Linear issue URL to get_issue', () => { + const match = matchExternalIssueUrl( + new URL('https://linear.app/acme/issue/ENG-123/fix-oauth'), + ); + + expect(match?.fetchAttempts).toEqual([ + { serverId: 'linear', toolName: 'get_issue', args: { id: 'ENG-123' } }, + ]); + }); + + it('ignores URLs no provider claims', () => { + expect( + matchExternalIssueUrl(new URL('https://github.com/acme/web/pull/301')), + ).toBeNull(); + expect( + matchExternalIssueUrl(new URL('https://example.com/acme/web/issues/42')), + ).toBeNull(); + }); + + it('supports a new provider through a single registry entry', () => { + const gitlab: ExternalIssueProvider = { + id: 'gitlab', + hostnames: ['gitlab.com'], + pathPattern: + /^\/(?[^/]+\/[^/]+)\/-\/issues\/(?\d+)(?:\/.*)?$/, + buildFetchAttempts: (groups) => [ + { + serverId: 'github', + toolName: 'get_issue', + args: { + project_id: groups.project, + issue_iid: Number(groups.issueNumber), + }, + }, + ], + }; + + const match = matchExternalIssueUrl( + new URL('https://gitlab.com/acme/web/-/issues/7'), + [gitlab], + ); + + expect(match?.fetchAttempts).toEqual([ + { + serverId: 'github', + toolName: 'get_issue', + args: { project_id: 'acme/web', issue_iid: 7 }, + }, + ]); + }); +}); diff --git a/packages/cloud-agents/src/server/router/external-issue-context.ts b/packages/cloud-agents/src/server/router/external-issue-context.ts index 44265bc7..40a01ac8 100644 --- a/packages/cloud-agents/src/server/router/external-issue-context.ts +++ b/packages/cloud-agents/src/server/router/external-issue-context.ts @@ -1,25 +1,20 @@ import type { ModelMessage } from 'ai'; import { callRouterMcpTool } from './mcp-tool-call'; +import { + matchExternalIssueUrl, + type ExternalIssueFetchAttempt, +} from './external-issue-providers'; import type { RoutingContext } from './types'; const MAX_EXTERNAL_ISSUES = 2; const MAX_EXTERNAL_CONTEXT_CHARS = 8_000; const EXTERNAL_LOOKUP_TIMEOUT_MS = 8_000; -type ExternalIssueReference = - | { - serverId: 'github'; - url: string; - owner: string; - repository: string; - issueNumber: number; - } - | { - serverId: 'linear'; - url: string; - identifier: string; - }; +interface ExternalIssueReference { + url: string; + fetchAttempts: readonly ExternalIssueFetchAttempt[]; +} function trimTrailingUrlPunctuation(value: string): string { let end = value.length; @@ -43,43 +38,10 @@ function parseExternalIssueReferences(text: string): ExternalIssueReference[] { const url = trimTrailingUrlPunctuation(rawUrl); try { - const parsedUrl = new URL(url); - const githubMatch = parsedUrl.pathname.match( - /^\/(?[^/]+)\/(?[^/]+)\/issues\/(?\d+)(?:\/.*)?$/, - ); - - const owner = githubMatch?.groups?.owner; - const repository = githubMatch?.groups?.repository; - const issueNumber = githubMatch?.groups?.issueNumber; - - if ( - parsedUrl.hostname === 'github.com' && - owner && - repository && - issueNumber - ) { - references.push({ - serverId: 'github', - url, - owner, - repository, - issueNumber: Number(issueNumber), - }); - continue; - } - - const linearMatch = parsedUrl.pathname.match( - /^\/[^/]+\/issue\/(?[A-Za-z]+-\d+)(?:\/.*)?$/, - ); - - const identifier = linearMatch?.groups?.identifier; + const match = matchExternalIssueUrl(new URL(url)); - if (parsedUrl.hostname === 'linear.app' && identifier) { - references.push({ - serverId: 'linear', - url, - identifier, - }); + if (match) { + references.push({ url, fetchAttempts: match.fetchAttempts }); } } catch { // Ignore malformed links; routing can still proceed from the task text. @@ -133,56 +95,23 @@ async function fetchExternalIssueContext( try { const deadline = Date.now() + EXTERNAL_LOOKUP_TIMEOUT_MS; - if (reference.serverId === 'linear') { + for (const attempt of reference.fetchAttempts) { const result = await withExternalLookupTimeout( callRouterMcpTool({ context, - serverId: 'linear', - toolName: 'get_issue', - args: { id: reference.identifier }, + serverId: attempt.serverId, + toolName: attempt.toolName, + args: attempt.args, }), deadline, ); - return result === null ? null : { toolName: 'linear.get_issue', result }; - } - - const issueReadResult = await withExternalLookupTimeout( - callRouterMcpTool({ - context, - serverId: 'github', - toolName: 'issue_read', - args: { - method: 'get', - owner: reference.owner, - repo: reference.repository, - issue_number: reference.issueNumber, - }, - }), - deadline, - ); - - if (issueReadResult !== null) { - return { toolName: 'github.issue_read', result: issueReadResult }; + if (result !== null) { + return { toolName: `${attempt.serverId}.${attempt.toolName}`, result }; + } } - const getIssueResult = await withExternalLookupTimeout( - callRouterMcpTool({ - context, - serverId: 'github', - toolName: 'get_issue', - args: { - owner: reference.owner, - repo: reference.repository, - issue_number: reference.issueNumber, - }, - }), - deadline, - ); - - return getIssueResult === null - ? null - : { toolName: 'github.get_issue', result: getIssueResult }; + return null; } catch { // A disconnected integration or inaccessible issue must not block routing. return null; diff --git a/packages/cloud-agents/src/server/router/external-issue-providers.ts b/packages/cloud-agents/src/server/router/external-issue-providers.ts new file mode 100644 index 00000000..26faa521 --- /dev/null +++ b/packages/cloud-agents/src/server/router/external-issue-providers.ts @@ -0,0 +1,95 @@ +import type { RouterMcpServerId } from './mcp-policy'; + +/** + * One MCP call the router may make to fetch a matched issue. Attempts run in + * order until one returns a result, so older tool names can trail newer ones + * as fallbacks. + */ +export interface ExternalIssueFetchAttempt { + serverId: RouterMcpServerId; + toolName: string; + args: Record; +} + +interface ExternalIssueUrlMatch { + fetchAttempts: readonly ExternalIssueFetchAttempt[]; +} + +/** + * Declarative issue-link provider: which hostnames it owns, the pathname + * shape that identifies one of its issues, and how a match turns into MCP + * fetch attempts. Adding SCM support is one new entry here (plus the MCP + * server itself and its mcp-policy allowlist) — parsing and fetching stay in + * deterministic code, never the model. + */ +export interface ExternalIssueProvider { + id: string; + hostnames: readonly string[]; + pathPattern: RegExp; + buildFetchAttempts( + groups: Record, + ): readonly ExternalIssueFetchAttempt[]; +} + +const EXTERNAL_ISSUE_PROVIDERS: readonly ExternalIssueProvider[] = [ + { + id: 'github', + hostnames: ['github.com'], + pathPattern: + /^\/(?[^/]+)\/(?[^/]+)\/issues\/(?\d+)(?:\/.*)?$/, + buildFetchAttempts: (groups) => [ + { + serverId: 'github', + toolName: 'issue_read', + args: { + method: 'get', + owner: groups.owner, + repo: groups.repository, + issue_number: Number(groups.issueNumber), + }, + }, + { + serverId: 'github', + toolName: 'get_issue', + args: { + owner: groups.owner, + repo: groups.repository, + issue_number: Number(groups.issueNumber), + }, + }, + ], + }, + { + id: 'linear', + hostnames: ['linear.app'], + pathPattern: /^\/[^/]+\/issue\/(?[A-Za-z]+-\d+)(?:\/.*)?$/, + buildFetchAttempts: (groups) => [ + { + serverId: 'linear', + toolName: 'get_issue', + args: { id: groups.identifier }, + }, + ], + }, +]; + +export function matchExternalIssueUrl( + url: URL, + providers: readonly ExternalIssueProvider[] = EXTERNAL_ISSUE_PROVIDERS, +): ExternalIssueUrlMatch | null { + for (const provider of providers) { + if (!provider.hostnames.includes(url.hostname)) { + continue; + } + + const groups = url.pathname.match(provider.pathPattern)?.groups; + + if (!groups || Object.values(groups).some((value) => value == null)) { + continue; + } + + return { fetchAttempts: provider.buildFetchAttempts(groups) }; + } + + return null; +}