Skip to content
Merged
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 19 additions & 90 deletions packages/cloud-agents/src/server/router/external-issue-context.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -43,43 +38,10 @@ function parseExternalIssueReferences(text: string): ExternalIssueReference[] {
const url = trimTrailingUrlPunctuation(rawUrl);

try {
const parsedUrl = new URL(url);
const githubMatch = parsedUrl.pathname.match(
/^\/(?<owner>[^/]+)\/(?<repository>[^/]+)\/issues\/(?<issueNumber>\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\/(?<identifier>[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.
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<string, string>,
): readonly ExternalIssueFetchAttempt[];
}

const EXTERNAL_ISSUE_PROVIDERS: readonly ExternalIssueProvider[] = [
{
id: 'github',
hostnames: ['github.com'],
pathPattern:
/^\/(?<owner>[^/]+)\/(?<repository>[^/]+)\/issues\/(?<issueNumber>\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\/(?<identifier>[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;
}
Loading