diff --git a/packages/sdk/src/pull-request-links.ts b/packages/sdk/src/pull-request-links.ts index 7576786c8..3f85ecefc 100644 --- a/packages/sdk/src/pull-request-links.ts +++ b/packages/sdk/src/pull-request-links.ts @@ -4,16 +4,22 @@ import { asBoolean, asRecord, asString, + buildPullRequestUrl, + parsePullRequestUrl, + sourceControlProviderDescriptors, + type SourceControlProvider, } from '@roomote/types'; import { z } from 'zod'; /** - * Represents a pull request parsed from command output. + * Represents a pull request parsed from command output or free text. */ export interface ParsedPR { url: string; repository: string; number: number; + provider: SourceControlProvider; + host: string; } /** @@ -28,9 +34,7 @@ const AUTHORITATIVE_PR_RESULT_NAMES = new Set(['create-draft-pr', 'create-pr']); * responsible for constraining when this parser runs, usually via command * guards like `gh pr create` / `gh pr list` detection. */ -const PR_URL_REGEX = /https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/; - -const GITHUB_URL_CANDIDATE_REGEX = /https:\/\/github\.com\/[^\s<>"']+/g; +const HTTPS_URL_CANDIDATE_REGEX = /https:\/\/[^\s<>"']+/g; const GH_PR_CREATE_COMMAND_REGEX = /\bgh\s+pr\s+create\b/; const GH_PR_CHECKOUT_COMMAND_REGEX = /\bgh\s+pr\s+checkout\b/; const GH_PR_LIST_COMMAND_REGEX = /\bgh\s+pr\s+list\b/; @@ -58,9 +62,6 @@ const AUTHORITATIVE_PR_TOOL_RESULT_ALLOWED_KEYS = new Set([ 'url', ]); -const PR_URL_WITH_OPTIONAL_SUFFIX_REGEX = - /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/; - const ANSI_ESCAPE_PATTERN = String.raw`\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\))`; const ANSI_ESCAPE_REGEX = new RegExp(ANSI_ESCAPE_PATTERN, 'g'); @@ -83,33 +84,72 @@ function trimTrailingUrlPunctuation(url: string): string { return trimmed; } -/** - * Parses a GitHub PR URL from command output text. - * - * Returns a {@link ParsedPR} when the trimmed output contains a GitHub PR URL, - * or `null` otherwise. - */ -export function parsePRFromOutput(output: string): ParsedPR | null { - const trimmed = stripAnsiSequences(output).trim(); - const match = trimmed.match(PR_URL_REGEX); - - if (!match) { +function toParsedPR(input: { + provider: SourceControlProvider; + host?: string; + repositoryFullName: string; + number: number; +}): ParsedPR | null { + if (!Number.isInteger(input.number) || input.number <= 0) { return null; } - const owner = match[1]!; - const repo = match[2]!; - const number = parseInt(match[3]!, 10); + const host = + input.host ?? sourceControlProviderDescriptors[input.provider].defaultHost; + + return { + url: buildPullRequestUrl({ + provider: input.provider, + host, + repositoryFullName: input.repositoryFullName, + number: input.number, + }), + repository: input.repositoryFullName, + number: input.number, + provider: input.provider, + host, + }; +} + +function parsePRFromUrlCandidate(url: string): ParsedPR | null { + const candidate = trimTrailingUrlPunctuation(url); + const ref = parsePullRequestUrl(candidate); - if (isNaN(number) || number <= 0) { + if (!ref) { return null; } - return { - url: `https://github.com/${owner}/${repo}/pull/${number}`, - repository: `${owner}/${repo}`, + return toParsedPR(ref); +} + +function githubParsedPR(repository: string, number: number): ParsedPR | null { + return toParsedPR({ + provider: 'github', + host: 'github.com', + repositoryFullName: repository, number, - }; + }); +} + +/** + * Parses a pull request / merge request URL from command output text. + * + * Returns a {@link ParsedPR} when the trimmed output contains a recognized + * provider PR URL (GitHub, GitLab, Gitea, or Azure DevOps), or `null` otherwise. + */ +export function parsePRFromOutput(output: string): ParsedPR | null { + const cleaned = stripAnsiSequences(output); + const matches = cleaned.matchAll(HTTPS_URL_CANDIDATE_REGEX); + + for (const match of matches) { + const parsed = parsePRFromUrlCandidate(match[0] ?? ''); + + if (parsed) { + return parsed; + } + } + + return null; } /** @@ -194,13 +234,9 @@ export function parsePRsFromGhPrCheckoutToolResult({ return []; } - return [ - { - url: `https://github.com/${repo}/pull/${prNumber}`, - repository: repo, - number: prNumber, - }, - ]; + const parsed = githubParsedPR(repo, prNumber); + + return parsed ? [parsed] : []; } /** @@ -243,30 +279,6 @@ export function parsePRsFromAuthoritativeToolResultOutput( return parsedPr ? [parsedPr] : []; } -function parsePRFromUrlCandidate(url: string): ParsedPR | null { - const parsed = trimTrailingUrlPunctuation(url).match( - PR_URL_WITH_OPTIONAL_SUFFIX_REGEX, - ); - - if (!parsed) { - return null; - } - - const owner = parsed[1]; - const repo = parsed[2]; - const number = Number.parseInt(parsed[3] ?? '', 10); - - if (!owner || !repo || Number.isNaN(number) || number <= 0) { - return null; - } - - return { - url: `https://github.com/${owner}/${repo}/pull/${number}`, - repository: `${owner}/${repo}`, - number, - }; -} - export function parseRepoFromCommand(command: string): string | null { const match = command.match(GH_REPO_FLAG_REGEX); const repo = match?.[1]; @@ -355,13 +367,9 @@ export function parsePRsFromGhPrListToolResult({ return []; } - return [ - { - url: `https://github.com/${repo}/pull/${number}`, - repository: repo, - number, - }, - ]; + const parsed = githubParsedPR(repo, number); + + return parsed ? [parsed] : []; } const prs: ParsedPR[] = []; @@ -373,19 +381,19 @@ export function parsePRsFromGhPrListToolResult({ } /** - * Parses one or more GitHub pull request URLs from arbitrary text. + * Parses one or more pull request / merge request URLs from arbitrary text. * * This supports assistant summaries that embed PR links in markdown (e.g. * backticks, inline prose, markdown links). Parsed URLs are normalized to the - * canonical `https://github.com/owner/repo/pull/123` shape and deduplicated. + * canonical provider-specific shape and deduplicated. */ export function parsePRsFromText(text: string): ParsedPR[] { - const matches = text.matchAll(GITHUB_URL_CANDIDATE_REGEX); + const matches = stripAnsiSequences(text).matchAll(HTTPS_URL_CANDIDATE_REGEX); const prs: ParsedPR[] = []; const seen = new Set(); for (const match of matches) { - const candidate = trimTrailingUrlPunctuation(match[0] ?? ''); + const candidate = match[0] ?? ''; if (!candidate) { continue; diff --git a/packages/sdk/src/server/lib/cloud-jobs/__tests__/extract-pull-requests.test.ts b/packages/sdk/src/server/lib/cloud-jobs/__tests__/extract-pull-requests.test.ts index b02061a33..967998392 100644 --- a/packages/sdk/src/server/lib/cloud-jobs/__tests__/extract-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/cloud-jobs/__tests__/extract-pull-requests.test.ts @@ -16,6 +16,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/owner/repo/pull/42', repository: 'owner/repo', number: 42, + provider: 'github', + host: 'github.com', }); }); @@ -28,6 +30,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/acme/frontend/pull/100', repository: 'acme/frontend', number: 100, + provider: 'github', + host: 'github.com', }); }); @@ -40,6 +44,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/vercel/next.js/pull/12345', repository: 'vercel/next.js', number: 12345, + provider: 'github', + host: 'github.com', }); }); @@ -54,6 +60,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/owner/repo/pull/42', repository: 'owner/repo', number: 42, + provider: 'github', + host: 'github.com', }); }); @@ -65,6 +73,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/owner/repo/pull/42', repository: 'owner/repo', number: 42, + provider: 'github', + host: 'github.com', }); }); @@ -72,12 +82,64 @@ describe('parsePRFromOutput', () => { expect(parsePRFromOutput('')).toBeNull(); }); - it('returns null for non-GitHub URLs', () => { + it('returns null for GitLab /pull/ URLs that are not merge-request paths', () => { expect( parsePRFromOutput('https://gitlab.com/owner/repo/pull/1'), ).toBeNull(); }); + it('parses a GitLab merge request URL', () => { + expect( + parsePRFromOutput('https://gitlab.com/acme/api/-/merge_requests/42'), + ).toEqual({ + url: 'https://gitlab.com/acme/api/-/merge_requests/42', + repository: 'acme/api', + number: 42, + provider: 'gitlab', + host: 'gitlab.com', + }); + }); + + it('parses a self-managed GitLab merge request URL', () => { + expect( + parsePRFromOutput( + 'https://gitlab.example.com/group/repo/-/merge_requests/7', + ), + ).toEqual({ + url: 'https://gitlab.example.com/group/repo/-/merge_requests/7', + repository: 'group/repo', + number: 7, + provider: 'gitlab', + host: 'gitlab.example.com', + }); + }); + + it('parses a Gitea pull request URL', () => { + expect( + parsePRFromOutput('https://git.example.com/team/repo/pulls/42'), + ).toEqual({ + url: 'https://git.example.com/team/repo/pulls/42', + repository: 'team/repo', + number: 42, + provider: 'gitea', + host: 'git.example.com', + }); + }); + + it('parses an Azure DevOps pull request URL', () => { + expect( + parsePRFromOutput( + 'https://dev.azure.com/acme/Platform/_git/backend/pullrequest/42', + ), + ).toEqual({ + url: 'https://dev.azure.com/acme/Platform/_git/backend/pullrequest/42', + repository: 'acme/Platform/backend', + number: 42, + provider: 'ado', + host: 'dev.azure.com', + }); + }); + it('returns null for http:// URLs (gh uses https)', () => { expect(parsePRFromOutput('http://github.com/owner/repo/pull/5')).toBeNull(); }); @@ -92,6 +154,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/owner/repo/pull/1', repository: 'owner/repo', number: 1, + provider: 'github', + host: 'github.com', }); }); @@ -104,6 +168,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/owner/repo/pull/99', repository: 'owner/repo', number: 99, + provider: 'github', + host: 'github.com', }); }); @@ -116,6 +182,8 @@ describe('parsePRFromOutput', () => { url: 'https://github.com/owner/repo/pull/123', repository: 'owner/repo', number: 123, + provider: 'github', + host: 'github.com', }); }); }); @@ -131,6 +199,8 @@ describe('parsePRsFromText', () => { url: 'https://github.com/owner/repo/pull/77', repository: 'owner/repo', number: 77, + provider: 'github', + host: 'github.com', }, ]); }); @@ -145,6 +215,8 @@ describe('parsePRsFromText', () => { url: 'https://github.com/owner/repo/pull/88', repository: 'owner/repo', number: 88, + provider: 'github', + host: 'github.com', }, ]); }); @@ -162,6 +234,8 @@ describe('parsePRsFromText', () => { url: 'https://github.com/owner/repo/pull/99', repository: 'owner/repo', number: 99, + provider: 'github', + host: 'github.com', }, ]); }); @@ -179,11 +253,49 @@ describe('parsePRsFromText', () => { url: 'https://github.com/owner/repo/pull/99', repository: 'owner/repo', number: 99, + provider: 'github', + host: 'github.com', }, { url: 'https://github.com/owner/repo/pull/100', repository: 'owner/repo', number: 100, + provider: 'github', + host: 'github.com', + }, + ]); + }); + + it('extracts GitLab, Gitea, and Azure DevOps MR links from completion text', () => { + const result = parsePRsFromText( + [ + 'Completed with https://gitlab.com/acme/api/-/merge_requests/42', + 'Also https://git.example.com/team/repo/pulls/9 and', + 'https://dev.azure.com/acme/Platform/_git/backend/pullrequest/7.', + ].join('\n'), + ); + + expect(result).toEqual([ + { + url: 'https://gitlab.com/acme/api/-/merge_requests/42', + repository: 'acme/api', + number: 42, + provider: 'gitlab', + host: 'gitlab.com', + }, + { + url: 'https://git.example.com/team/repo/pulls/9', + repository: 'team/repo', + number: 9, + provider: 'gitea', + host: 'git.example.com', + }, + { + url: 'https://dev.azure.com/acme/Platform/_git/backend/pullrequest/7', + repository: 'acme/Platform/backend', + number: 7, + provider: 'ado', + host: 'dev.azure.com', }, ]); }); @@ -202,6 +314,8 @@ describe('parsePRsFromGhPrCreateToolResult', () => { url: 'https://github.com/test/repo/pull/555', repository: 'test/repo', number: 555, + provider: 'github', + host: 'github.com', }, ]); }); @@ -221,11 +335,15 @@ describe('parsePRsFromGhPrCreateToolResult', () => { url: 'https://github.com/test/one/pull/11', repository: 'test/one', number: 11, + provider: 'github', + host: 'github.com', }, { url: 'https://github.com/test/two/pull/22', repository: 'test/two', number: 22, + provider: 'github', + host: 'github.com', }, ]); }); @@ -241,6 +359,8 @@ describe('parsePRsFromGhPrCreateToolResult', () => { url: 'https://github.com/test/repo/pull/777', repository: 'test/repo', number: 777, + provider: 'github', + host: 'github.com', }, ]); }); @@ -261,6 +381,8 @@ describe('parsePRsFromGhPrCreateToolResult', () => { url: 'https://github.com/Roomote/example-app/pull/2345', repository: 'Roomote/example-app', number: 2345, + provider: 'github', + host: 'github.com', }, ]); }); @@ -281,6 +403,8 @@ describe('parsePRsFromGhPrCreateToolResult', () => { url: 'https://github.com/Roomote/example-app/pull/2587', repository: 'Roomote/example-app', number: 2587, + provider: 'github', + host: 'github.com', }, ]); }); @@ -313,6 +437,8 @@ describe('parsePRsFromGhPrCheckoutToolResult', () => { url: 'https://github.com/Roomote/example-app/pull/2696', repository: 'Roomote/example-app', number: 2696, + provider: 'github', + host: 'github.com', }, ]); }); @@ -348,6 +474,8 @@ describe('parsePRsFromGhPrCheckoutToolResult', () => { url: 'https://github.com/Roomote/example-app/pull/2696', repository: 'Roomote/example-app', number: 2696, + provider: 'github', + host: 'github.com', }, ]); }); @@ -365,6 +493,8 @@ describe('parsePRsFromGhPrCheckoutToolResult', () => { url: 'https://github.com/other/repo/pull/2696', repository: 'other/repo', number: 2696, + provider: 'github', + host: 'github.com', }, ]); }); @@ -383,6 +513,8 @@ describe('parsePRsFromGhPrListToolResult', () => { url: 'https://github.com/test/repo/pull/555', repository: 'test/repo', number: 555, + provider: 'github', + host: 'github.com', }, ]); }); @@ -435,6 +567,8 @@ describe('parsePRsFromGhPrListToolResult', () => { url: 'https://github.com/test/repo/pull/555', repository: 'test/repo', number: 555, + provider: 'github', + host: 'github.com', }, ]); }); @@ -470,6 +604,8 @@ describe('parsePRsFromAuthoritativeToolResultOutput', () => { url: 'https://github.com/test/repo/pull/556', repository: 'test/repo', number: 556, + provider: 'github', + host: 'github.com', }, ]); }); @@ -490,6 +626,8 @@ describe('parsePRsFromAuthoritativeToolResultOutput', () => { url: 'https://github.com/test/repo/pull/556', repository: 'test/repo', number: 556, + provider: 'github', + host: 'github.com', }, ]); }); @@ -512,6 +650,30 @@ describe('parsePRsFromAuthoritativeToolResultOutput', () => { url: 'https://github.com/test/repo/pull/556', repository: 'test/repo', number: 556, + provider: 'github', + host: 'github.com', + }, + ]); + }); + + it('extracts a multi-provider MR from an authoritative delivery payload', () => { + const result = parsePRsFromAuthoritativeToolResultOutput( + JSON.stringify({ + baseRefName: 'main', + headRefName: 'feature/gitlab-link', + isDraft: false, + title: '[Feat] Ship multi-provider link', + url: 'https://gitlab.com/acme/api/-/merge_requests/42', + }), + ); + + expect(result).toEqual([ + { + url: 'https://gitlab.com/acme/api/-/merge_requests/42', + repository: 'acme/api', + number: 42, + provider: 'gitlab', + host: 'gitlab.com', }, ]); }); @@ -532,6 +694,8 @@ describe('parsePRsFromAuthoritativeToolResultOutput', () => { url: 'https://github.com/test/repo/pull/556', repository: 'test/repo', number: 556, + provider: 'github', + host: 'github.com', }, ]); }); diff --git a/packages/sdk/src/server/lib/cloud-jobs/extract-pull-requests.ts b/packages/sdk/src/server/lib/cloud-jobs/extract-pull-requests.ts index f58746eb1..f12a89986 100644 --- a/packages/sdk/src/server/lib/cloud-jobs/extract-pull-requests.ts +++ b/packages/sdk/src/server/lib/cloud-jobs/extract-pull-requests.ts @@ -9,8 +9,9 @@ import { } from '@roomote/db/server'; import type { CloudJob } from '@roomote/db/server'; import { createCloudJobGitHubToken, getOctokit } from '@roomote/github'; -import type { PullRequestStatus } from '@roomote/types'; +import type { PullRequestStatus, SourceControlProvider } from '@roomote/types'; import type { ParsedPR } from '../../../pull-request-links'; +import { readSourceControlPullRequestForCloudJob } from '../pull-requests/source-control-pull-request-reads'; export { detectPullRequestsFromToolResultEnvelope, @@ -28,7 +29,7 @@ export { * degrade gracefully. The base ref and sha are persisted alongside the PR * association as cloud-job PR metadata. */ -async function fetchPrDetails( +async function fetchGitHubPrDetails( cloudJob: CloudJob, owner: string, repo: string, @@ -78,13 +79,108 @@ async function fetchPrDetails( } } +function mapProviderReadStatus(details: { + state: 'open' | 'closed' | 'merged'; + draft: boolean; +}): PullRequestStatus { + if (details.state === 'merged') { + return 'merged'; + } + + if (details.state === 'closed') { + return 'closed'; + } + + if (details.draft) { + return 'draft'; + } + + return 'open'; +} + +/** + * Fetches title/status/base metadata for a detected PR URL. GitHub keeps the + * installation-token path (works even when the repo row is unlinked). Other + * providers reuse the multi-provider read surface, which owns token resolution + * and status mapping. + */ +async function fetchDetectedPrDetails( + cloudJob: CloudJob, + pr: ParsedPR, +): Promise<{ + title: string; + status: PullRequestStatus; + baseRef: string | null; + baseSha: string | null; +} | null> { + if (pr.provider === 'github') { + const [owner, repoName] = pr.repository.split('/'); + + if (!owner || !repoName) { + return null; + } + + return fetchGitHubPrDetails(cloudJob, owner, repoName, pr.number); + } + + try { + const result = await readSourceControlPullRequestForCloudJob({ + cloudJob, + input: { + action: 'get_pull_request', + repositoryFullName: pr.repository, + prNumber: pr.number, + sourceControlProvider: pr.provider, + }, + }); + + if (!('title' in result)) { + return null; + } + + return { + title: result.title, + status: mapProviderReadStatus(result), + baseRef: result.targetBranch || null, + baseSha: result.baseSha, + }; + } catch (error) { + console.warn( + `[fetchPrDetails] Failed to fetch details for ${pr.provider} ${pr.repository}#${pr.number}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + return null; + } +} + +async function resolveLinkedRepository({ + provider, + repositoryFullName, +}: { + provider: SourceControlProvider; + repositoryFullName: string; +}): Promise<{ id: string; host: string | null } | null> { + const linkedRepository = await db.query.repositories.findFirst({ + where: and( + eq(repositories.sourceControlProvider, provider), + eq(repositories.fullName, repositoryFullName), + ), + columns: { id: true, host: true }, + }); + + return linkedRepository ?? null; +} + /** * Persists a detected pull request to the database: * - * 1. Fetches the PR title from the GitHub API. - * 2. Inserts into `task_pull_requests` (upsert via onConflictDoNothing - * so the same PR URL for a given task is never duplicated). - * 3. Updates the `cloudJobs` row with `prRepo`, `prNumber`, and the + * 1. Fetches the PR title/status from the matching source-control API when + * possible. + * 2. Inserts into `task_pull_requests` (upsert via onConflictDoUpdate so the + * same PR URL for a given task is never duplicated). + * 3. Updates the `cloudJobs` row with provider, `prRepo`, `prNumber`, and the * PR base ref so the association is visible in the dashboard. */ export async function persistDetectedPullRequest({ @@ -96,20 +192,19 @@ export async function persistDetectedPullRequest({ cloudJobId: number; pr: ParsedPR; }): Promise { - // Load the full cloud job to get installation context for GitHub API. + // Load the full cloud job to get installation/token context for API lookups. const cloudJob = await db.query.cloudJobs.findFirst({ where: eq(cloudJobs.id, cloudJobId), }); - const [owner, repoName] = pr.repository.split('/'); let prTitle: string | null = null; let status: PullRequestStatus | null = null; let baseRef: string | null = null; let baseSha: string | null = null; let fetchedPrDetails = false; - if (cloudJob && owner && repoName) { - const details = await fetchPrDetails(cloudJob, owner, repoName, pr.number); + if (cloudJob) { + const details = await fetchDetectedPrDetails(cloudJob, pr); if (details) { fetchedPrDetails = true; @@ -120,24 +215,23 @@ export async function persistDetectedPullRequest({ } } - // The transcript parser only emits canonical https://github.com/... PR URLs, - // so detected PRs are GitHub rows by construction. Resolve the linked - // repository row (nullable when the repo isn't linked) so the - // provider-scoped FK is set for new associations. - const linkedRepository = await db.query.repositories.findFirst({ - where: and( - eq(repositories.sourceControlProvider, 'github'), - eq(repositories.fullName, pr.repository), - ), - columns: { id: true }, + // Resolve the linked repository row by the provider decoded from the PR URL + // (nullable when the repo isn't linked) so the provider-scoped FK is set for + // new associations. + const linkedRepository = await resolveLinkedRepository({ + provider: pr.provider, + repositoryFullName: pr.repository, }); + // Prefer the host decoded from the PR URL so self-managed instances keep the + // instance that produced the link, even when a linked row has no host yet. + const host = pr.host || linkedRepository?.host || null; await db .insert(taskPullRequests) .values({ taskId, - sourceControlProvider: 'github', - host: 'github.com', + sourceControlProvider: pr.provider, + host, repositoryId: linkedRepository?.id ?? null, prUrl: pr.url, prNumber: pr.number, @@ -148,8 +242,8 @@ export async function persistDetectedPullRequest({ .onConflictDoUpdate({ target: [taskPullRequests.taskId, taskPullRequests.prUrl], set: { - sourceControlProvider: 'github', - host: 'github.com', + sourceControlProvider: pr.provider, + host, ...(linkedRepository && { repositoryId: linkedRepository.id }), ...(prTitle !== null && { prTitle }), ...(status !== null && { status }), @@ -171,7 +265,7 @@ export async function persistDetectedPullRequest({ await db .update(cloudJobs) .set({ - prSourceControlProvider: 'github', + prSourceControlProvider: pr.provider, prRepo: pr.repository, prNumber: pr.number, prBaseRef: fetchedPrDetails diff --git a/packages/sdk/src/server/lib/cloud-jobs/slack-pr-inactivity-check.ts b/packages/sdk/src/server/lib/cloud-jobs/slack-pr-inactivity-check.ts index 5630aed6f..d861d6e30 100644 --- a/packages/sdk/src/server/lib/cloud-jobs/slack-pr-inactivity-check.ts +++ b/packages/sdk/src/server/lib/cloud-jobs/slack-pr-inactivity-check.ts @@ -112,7 +112,14 @@ async function resolvePullRequestTarget({ cloudJob: CloudJob; completionText?: string; }): Promise<{ repository: string; prNumber: number } | null> { - if (cloudJob.prRepo && cloudJob.prNumber) { + // This storage path fetches baselines via the GitHub API, so only resolve + // GitHub targets here. Multi-provider associations still persist via + // transcript PR detection; inactivity checks outside GitHub are out of scope. + if ( + cloudJob.prRepo && + cloudJob.prNumber && + (cloudJob.prSourceControlProvider ?? 'github') === 'github' + ) { return { repository: cloudJob.prRepo, prNumber: cloudJob.prNumber, @@ -122,6 +129,7 @@ async function resolvePullRequestTarget({ const latestTaskPullRequest = await db.query.taskPullRequests.findFirst({ where: and( eq(taskPullRequests.taskId, cloudJob.taskId), + eq(taskPullRequests.sourceControlProvider, 'github'), isNotNull(taskPullRequests.repository), isNotNull(taskPullRequests.prNumber), ), @@ -143,7 +151,9 @@ async function resolvePullRequestTarget({ return null; } - const parsed = parsePRsFromText(completionText); + const parsed = parsePRsFromText(completionText).filter( + (candidate) => candidate.provider === 'github', + ); if (parsed.length !== 1) { return null;