From 6deb359c3fb7c35b5a4da423dade76300e8d23cf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:59:19 -0700 Subject: [PATCH] Extract hosted review forge provider contract --- .../source-control/forge-provider.test.ts | 188 +++++++++++++ src/main/source-control/forge-provider.ts | 266 ++++++++++++++++++ .../hosted-review-creation.test.ts | 19 +- .../source-control/hosted-review-creation.ts | 42 +-- src/main/source-control/hosted-review.test.ts | 3 +- src/main/source-control/hosted-review.ts | 210 +++----------- 6 files changed, 514 insertions(+), 214 deletions(-) create mode 100644 src/main/source-control/forge-provider.test.ts create mode 100644 src/main/source-control/forge-provider.ts diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts new file mode 100644 index 00000000000..fc023efc086 --- /dev/null +++ b/src/main/source-control/forge-provider.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + getAzureDevOpsRepoSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getMergeRequestForBranchMock, + getProjectSlugMock, + getPRForBranchMock, + getRepoSlugMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getMergeRequestForBranchMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getPRForBranchMock: vi.fn(), + getRepoSlugMock: vi.fn() +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: getMergeRequestForBranchMock, + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: getPRForBranchMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +import { + FORGE_PROVIDERS, + detectHostedReviewProvider, + getForgeProviderById, + getForgeProviderForRepository +} from './forge-provider' + +describe('forge provider interface', () => { + beforeEach(() => { + createGitHubPullRequestMock.mockReset() + createGitLabMergeRequestMock.mockReset() + getAzureDevOpsRepoSlugMock.mockReset() + getBitbucketRepoSlugMock.mockReset() + getGiteaRepoSlugMock.mockReset() + getMergeRequestForBranchMock.mockReset() + getProjectSlugMock.mockReset() + getPRForBranchMock.mockReset() + getRepoSlugMock.mockReset() + }) + + it('preserves the existing hosted provider detection order', async () => { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'team/orca' }) + getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca' }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitlab') + await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({ + id: 'gitlab' + }) + expect(getRepoSlugMock).not.toHaveBeenCalled() + }) + + it('keeps review creation capability scoped to providers with creation support', async () => { + expect( + FORGE_PROVIDERS.map((provider) => [provider.id, provider.supportsReviewCreation]) + ).toEqual([ + ['gitlab', true], + ['github', true], + ['bitbucket', false], + ['azure-devops', false], + ['gitea', false] + ]) + createGitHubPullRequestMock.mockResolvedValue({ + ok: true, + number: 12, + url: 'https://github.com/team/orca/pull/12' + }) + + const provider = getForgeProviderById('github') + await expect( + provider.createReview?.('/repo', { + provider: 'github', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/team/orca/pull/12' + }) + expect(createGitHubPullRequestMock).toHaveBeenCalledWith('/repo', { + provider: 'github', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }) + }) + + it('routes GitLab review creation through the shared provider contract', async () => { + createGitLabMergeRequestMock.mockResolvedValue({ + ok: true, + number: 44, + url: 'https://gitlab.com/team/orca/-/merge_requests/44' + }) + + const provider = getForgeProviderById('gitlab') + await expect( + provider.createReview?.( + '/repo', + { + provider: 'gitlab', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }, + 'ssh-1' + ) + ).resolves.toEqual({ + ok: true, + number: 44, + url: 'https://gitlab.com/team/orca/-/merge_requests/44' + }) + expect(createGitLabMergeRequestMock).toHaveBeenCalledWith( + '/repo', + { + provider: 'gitlab', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }, + 'ssh-1' + ) + }) + + it('adapts GitHub branch lookup through the shared provider contract', async () => { + getPRForBranchMock.mockResolvedValue({ + number: 7, + title: 'Provider branch', + state: 'open', + url: 'https://github.com/team/orca/pull/7', + checksStatus: 'success', + updatedAt: '2026-05-29T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + + await expect( + getForgeProviderById('github').getReviewForBranch({ + repoPath: '/repo', + connectionId: 'ssh-1', + branch: '', + fallbackReviewNumber: 7 + }) + ).resolves.toMatchObject({ + provider: 'github', + number: 7, + status: 'success' + }) + expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', '', null, 'ssh-1', 7) + }) +}) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts new file mode 100644 index 00000000000..41fa093792d --- /dev/null +++ b/src/main/source-control/forge-provider.ts @@ -0,0 +1,266 @@ +import type { + CreateHostedReviewInput, + CreateHostedReviewResult, + HostedReviewInfo, + HostedReviewProvider +} from '../../shared/hosted-review' +import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' +import type { MRInfo, PRInfo } from '../../shared/types' +import { + getAzureDevOpsPullRequest, + getAzureDevOpsPullRequestForBranch, + getAzureDevOpsRepoSlug +} from '../azure-devops/client' +import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' +import { + getBitbucketPullRequest, + getBitbucketPullRequestForBranch, + getBitbucketRepoSlug +} from '../bitbucket/client' +import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' +import { + getGiteaPullRequest, + getGiteaPullRequestForBranch, + getGiteaRepoSlug +} from '../gitea/client' +import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' +import { createGitHubPullRequest, getPRForBranch, getRepoSlug } from '../github/client' +import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' +import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' + +export type ForgeProviderId = Exclude + +export type ForgeProviderRepositoryContext = { + repoPath: string + connectionId?: string | null +} + +export type ForgeReviewForBranchInput = ForgeProviderRepositoryContext & { + branch: string + linkedReviewNumber?: number | null + fallbackReviewNumber?: number | null +} + +export type ForgeReviewByNumberInput = ForgeProviderRepositoryContext & { + number: number +} + +export type ForgeProvider = { + id: ForgeProviderId + supportsReviewCreation: boolean + resolveRepository(context: ForgeProviderRepositoryContext): Promise + getReviewForBranch(input: ForgeReviewForBranchInput): Promise + getReviewByNumber(input: ForgeReviewByNumberInput): Promise + createReview?( + repoPath: string, + input: CreateHostedReviewInput, + connectionId?: string | null + ): Promise +} + +function mapGitHubReview(pr: PRInfo): HostedReviewInfo { + return hostedReviewInfoFromGitHubPRInfo(pr) +} + +function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { + if (state === 'opened' || state === 'locked') { + return 'open' + } + return state +} + +function mapGitLabReview(mr: MRInfo): HostedReviewInfo { + return { + provider: 'gitlab', + number: mr.number, + title: mr.title, + state: mapGitLabReviewState(mr.state), + url: mr.url, + status: mr.pipelineStatus, + updatedAt: mr.updatedAt, + mergeable: mr.mergeable, + ...(mr.headSha ? { headSha: mr.headSha } : {}), + ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) + } +} + +function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { + return { + provider: 'bitbucket', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { + return { + provider: 'azure-devops', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { + return { + provider: 'gitea', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +const gitLabForgeProvider = { + id: 'gitlab', + supportsReviewCreation: true, + resolveRepository: ({ repoPath, connectionId }) => getProjectSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const mr = await getMergeRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return mr ? mapGitLabReview(mr) : null + }, + async getReviewByNumber(input) { + const mr = await getMergeRequest(input.repoPath, input.number, input.connectionId) + return mr ? mapGitLabReview(mr) : null + }, + createReview: createGitLabMergeRequest +} satisfies ForgeProvider + +const gitHubForgeProvider = { + id: 'github', + supportsReviewCreation: true, + resolveRepository: ({ repoPath, connectionId }) => getRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const fallbackReviewNumber = + input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null + const pr = + fallbackReviewNumber !== null + ? await getPRForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId, + fallbackReviewNumber + ) + : await getPRForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapGitHubReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getPRForBranch(input.repoPath, '', input.number, input.connectionId) + return pr ? mapGitHubReview(pr) : null + }, + createReview: createGitHubPullRequest +} satisfies ForgeProvider + +const bitbucketForgeProvider = { + id: 'bitbucket', + supportsReviewCreation: false, + resolveRepository: ({ repoPath, connectionId }) => getBitbucketRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const pr = await getBitbucketPullRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapBitbucketReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getBitbucketPullRequest(input.repoPath, input.number, input.connectionId) + return pr ? mapBitbucketReview(pr) : null + } +} satisfies ForgeProvider + +const azureDevOpsForgeProvider = { + id: 'azure-devops', + supportsReviewCreation: false, + resolveRepository: ({ repoPath, connectionId }) => getAzureDevOpsRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const pr = await getAzureDevOpsPullRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapAzureDevOpsReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number, input.connectionId) + return pr ? mapAzureDevOpsReview(pr) : null + } +} satisfies ForgeProvider + +const giteaForgeProvider = { + id: 'gitea', + supportsReviewCreation: false, + resolveRepository: ({ repoPath, connectionId }) => getGiteaRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const pr = await getGiteaPullRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapGiteaReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getGiteaPullRequest(input.repoPath, input.number, input.connectionId) + return pr ? mapGiteaReview(pr) : null + } +} satisfies ForgeProvider + +// Why: provider order preserves existing branch-status behavior when remotes +// could be interpreted by more than one hosting integration. +export const FORGE_PROVIDERS = [ + gitLabForgeProvider, + gitHubForgeProvider, + bitbucketForgeProvider, + azureDevOpsForgeProvider, + giteaForgeProvider +] as const satisfies readonly ForgeProvider[] + +export function getForgeProviderById(id: ForgeProviderId): ForgeProvider { + return FORGE_PROVIDERS.find((provider) => provider.id === id) ?? gitHubForgeProvider +} + +export async function getForgeProviderForRepository( + context: ForgeProviderRepositoryContext +): Promise { + for (const provider of FORGE_PROVIDERS) { + if (await provider.resolveRepository(context)) { + return provider + } + } + return null +} + +export async function detectHostedReviewProvider( + context: ForgeProviderRepositoryContext +): Promise { + return (await getForgeProviderForRepository(context))?.id ?? 'unsupported' +} diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 1efd0f00b49..117f6ffcbf8 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -33,11 +33,14 @@ const { vi.mock('../github/client', () => ({ createGitHubPullRequest: createGitHubPullRequestMock, - getRepoSlug: getRepoSlugMock + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() })) vi.mock('../gitlab/client', () => ({ - getProjectSlug: getProjectSlugMock + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() })) vi.mock('../gitlab/merge-request-creation', () => ({ @@ -45,15 +48,21 @@ vi.mock('../gitlab/merge-request-creation', () => ({ })) vi.mock('../bitbucket/client', () => ({ - getBitbucketRepoSlug: getBitbucketRepoSlugMock + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() })) vi.mock('../azure-devops/client', () => ({ - getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() })) vi.mock('../gitea/client', () => ({ - getGiteaRepoSlug: getGiteaRepoSlugMock + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() })) vi.mock('../github/gh-utils', () => ({ diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index b22db643eec..2dbde4b03ec 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -12,10 +12,6 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { getAzureDevOpsRepoSlug } from '../azure-devops/client' -import { getBitbucketRepoSlug } from '../bitbucket/client' -import { getGiteaRepoSlug } from '../gitea/client' -import { createGitHubPullRequest, getRepoSlug } from '../github/client' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -23,7 +19,6 @@ import { gitOptionalLocksDisabledEnv } from '../git/runner' import { resolveDefaultBaseRefViaExec } from '../git/repo' import { getUpstreamStatus } from '../git/upstream' import { getProjectSlug } from '../gitlab/client' -import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { acquire as acquireGlab, glabExecFileAsync, @@ -31,6 +26,7 @@ import { release as releaseGlab } from '../gitlab/gl-utils' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { detectHostedReviewProvider, getForgeProviderForRepository } from './forge-provider' import { getHostedReviewForBranch } from './hosted-review' type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { @@ -41,28 +37,6 @@ function stripRefPrefix(ref: string): string { return normalizeHostedReviewHeadRef(ref) } -async function detectHostedReviewProvider( - repoPath: string, - connectionId?: string | null -): Promise { - if (await getProjectSlug(repoPath, connectionId)) { - return 'gitlab' - } - if (await getRepoSlug(repoPath, connectionId)) { - return 'github' - } - if (await getBitbucketRepoSlug(repoPath, connectionId)) { - return 'bitbucket' - } - if (await getAzureDevOpsRepoSlug(repoPath, connectionId)) { - return 'azure-devops' - } - if (await getGiteaRepoSlug(repoPath, connectionId)) { - return 'gitea' - } - return 'unsupported' -} - async function isGitHubAuthenticated( repoPath: string, connectionId?: string | null @@ -335,7 +309,10 @@ export async function getHostedReviewCreationEligibility( args: HostedReviewCreationEligibilityInput ): Promise { const branch = stripRefPrefix(args.branch).trim() - const provider = await detectHostedReviewProvider(args.repoPath, args.connectionId) + const provider = await detectHostedReviewProvider({ + repoPath: args.repoPath, + connectionId: args.connectionId + }) const defaultBaseRef = args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId)) const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null @@ -422,8 +399,8 @@ export async function createHostedReview( error: 'Creating reviews for this provider is not supported yet.' } } - const provider = await detectHostedReviewProvider(repoPath, connectionId) - if (provider !== input.provider) { + const provider = await getForgeProviderForRepository({ repoPath, connectionId }) + if (provider?.id !== input.provider || !provider.createReview) { const copy = reviewCopy(input.provider) return { ok: false, @@ -435,8 +412,5 @@ export async function createHostedReview( if (blocked) { return blocked } - if (input.provider === 'gitlab') { - return createGitLabMergeRequest(repoPath, input, connectionId) - } - return createGitHubPullRequest(repoPath, input, connectionId) + return provider.createReview(repoPath, input, connectionId) } diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 4573144a84d..cf97faa5291 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -32,7 +32,8 @@ vi.mock('../gitlab/client', () => ({ vi.mock('../github/client', () => ({ getRepoSlug: getRepoSlugMock, - getPRForBranch: getPRForBranchMock + getPRForBranch: getPRForBranchMock, + createGitHubPullRequest: vi.fn() })) vi.mock('../bitbucket/client', () => ({ diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index 05b308cf4e3..37de21baf66 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -1,92 +1,28 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' -import type { MRInfo, PRInfo } from '../../shared/types' -import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' import { - getAzureDevOpsPullRequest, - getAzureDevOpsPullRequestForBranch, - getAzureDevOpsRepoSlug -} from '../azure-devops/client' -import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' -import { - getBitbucketPullRequest, - getBitbucketPullRequestForBranch, - getBitbucketRepoSlug -} from '../bitbucket/client' -import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' -import { - getGiteaPullRequest, - getGiteaPullRequestForBranch, - getGiteaRepoSlug -} from '../gitea/client' -import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' -import { getPRForBranch, getRepoSlug } from '../github/client' -import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' + getForgeProviderById, + getForgeProviderForRepository, + type ForgeProviderId +} from './forge-provider' -function mapGitHubReview(pr: PRInfo): HostedReviewInfo { - return hostedReviewInfoFromGitHubPRInfo(pr) -} - -function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { - if (state === 'opened' || state === 'locked') { - return 'open' - } - return state -} - -function mapGitLabReview(mr: MRInfo): HostedReviewInfo { - return { - provider: 'gitlab', - number: mr.number, - title: mr.title, - state: mapGitLabReviewState(mr.state), - url: mr.url, - status: mr.pipelineStatus, - updatedAt: mr.updatedAt, - mergeable: mr.mergeable, - ...(mr.headSha ? { headSha: mr.headSha } : {}), - ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) - } -} - -function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { - return { - provider: 'bitbucket', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } -} - -function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { - return { - provider: 'azure-devops', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } -} - -function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { - return { - provider: 'gitea', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) +function reviewLinkForProvider( + input: Parameters[0], + provider: ForgeProviderId +): { linkedReviewNumber?: number | null; fallbackReviewNumber?: number | null } { + switch (provider) { + case 'github': + return { + linkedReviewNumber: input.linkedGitHubPR ?? null, + fallbackReviewNumber: input.linkedGitHubPR == null ? (input.fallbackGitHubPR ?? null) : null + } + case 'gitlab': + return { linkedReviewNumber: input.linkedGitLabMR ?? null } + case 'bitbucket': + return { linkedReviewNumber: input.linkedBitbucketPR ?? null } + case 'azure-devops': + return { linkedReviewNumber: input.linkedAzureDevOpsPR ?? null } + case 'gitea': + return { linkedReviewNumber: input.linkedGiteaPR ?? null } } } @@ -116,99 +52,25 @@ export async function getHostedReviewForBranch(input: { return null } - // Why: branch review status is tied to the branch publishing remote. - // GitHub and GitLab task/project surfaces may use richer per-provider - // source preferences, but this core status should follow origin. - const gitlabProject = await getProjectSlug(input.repoPath, input.connectionId) - if (gitlabProject) { - const mr = - (await getMergeRequestForBranch( - input.repoPath, - branchName, - input.linkedGitLabMR ?? null, - input.connectionId - )) ?? null - return mr ? mapGitLabReview(mr) : null + const provider = await getForgeProviderForRepository({ + repoPath: input.repoPath, + connectionId: input.connectionId + }) + if (!provider) { + return null } - - const githubRepo = await getRepoSlug(input.repoPath, input.connectionId) - if (githubRepo) { - const fallbackGitHubPR = input.linkedGitHubPR == null ? (input.fallbackGitHubPR ?? null) : null - const pr = - fallbackGitHubPR !== null - ? await getPRForBranch( - input.repoPath, - branchName, - input.linkedGitHubPR ?? null, - input.connectionId, - fallbackGitHubPR - ) - : await getPRForBranch( - input.repoPath, - branchName, - input.linkedGitHubPR ?? null, - input.connectionId - ) - return pr ? mapGitHubReview(pr) : null - } - - const bitbucketRepo = await getBitbucketRepoSlug(input.repoPath, input.connectionId) - if (bitbucketRepo) { - const pr = await getBitbucketPullRequestForBranch( - input.repoPath, - branchName, - input.linkedBitbucketPR ?? null, - input.connectionId - ) - return pr ? mapBitbucketReview(pr) : null - } - - const azureDevOpsRepo = await getAzureDevOpsRepoSlug(input.repoPath, input.connectionId) - if (azureDevOpsRepo) { - const pr = await getAzureDevOpsPullRequestForBranch( - input.repoPath, - branchName, - input.linkedAzureDevOpsPR ?? null, - input.connectionId - ) - return pr ? mapAzureDevOpsReview(pr) : null - } - - const giteaRepo = await getGiteaRepoSlug(input.repoPath, input.connectionId) - if (giteaRepo) { - const pr = await getGiteaPullRequestForBranch( - input.repoPath, - branchName, - input.linkedGiteaPR ?? null, - input.connectionId - ) - return pr ? mapGiteaReview(pr) : null - } - - return null + return provider.getReviewForBranch({ + repoPath: input.repoPath, + connectionId: input.connectionId, + branch: branchName, + ...reviewLinkForProvider(input, provider.id) + }) } export async function getHostedReviewByNumber(input: { repoPath: string - provider: 'github' | 'gitlab' | 'bitbucket' | 'azure-devops' | 'gitea' + provider: ForgeProviderId number: number }): Promise { - if (input.provider === 'gitlab') { - const mr = await getMergeRequest(input.repoPath, input.number) - return mr ? mapGitLabReview(mr) : null - } - if (input.provider === 'bitbucket') { - const pr = await getBitbucketPullRequest(input.repoPath, input.number) - return pr ? mapBitbucketReview(pr) : null - } - if (input.provider === 'azure-devops') { - const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number) - return pr ? mapAzureDevOpsReview(pr) : null - } - if (input.provider === 'gitea') { - const pr = await getGiteaPullRequest(input.repoPath, input.number) - return pr ? mapGiteaReview(pr) : null - } - const pr = await getPRForBranch(input.repoPath, '', input.number) - return pr ? mapGitHubReview(pr) : null + return getForgeProviderById(input.provider).getReviewByNumber(input) }