Fix SSH hosted review provider detection (#3618)

* Fix SSH hosted review provider detection

* test: cover ssh hosted review cache retries

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-30 13:12:37 -07:00 committed by GitHub
parent bc11db4e9f
commit d67d8defa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 293 additions and 70 deletions

View File

@ -254,14 +254,12 @@ export async function getAzureDevOpsAuthStatus(): Promise<AzureDevOpsAuthStatus>
export async function getAzureDevOpsPullRequest(
repoPath: string,
prNumber: number
prNumber: number,
connectionId?: string | null
): Promise<AzureDevOpsPullRequestInfo | null> {
const repo = await getAzureDevOpsRepoRef(repoPath)
if (!repo) {
return null
}
const repository = await getRepository(repo)
if (!repository) {
const repo = await getAzureDevOpsRepoRef(repoPath, connectionId)
const repository = repo ? await getRepository(repo) : null
if (!repo || !repository) {
return null
}
const raw = await requestJson<RawAzureDevOpsPullRequest>(
@ -276,19 +274,17 @@ export async function getAzureDevOpsPullRequest(
export async function getAzureDevOpsPullRequestForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null
linkedPRNumber?: number | null,
connectionId?: string | null
): Promise<AzureDevOpsPullRequestInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
return null
}
const repo = await getAzureDevOpsRepoRef(repoPath)
if (!repo) {
return null
}
const repository = await getRepository(repo)
if (!repository) {
const repo = await getAzureDevOpsRepoRef(repoPath, connectionId)
const repository = repo ? await getRepository(repo) : null
if (!repo || !repository) {
return null
}
@ -322,6 +318,9 @@ export async function getAzureDevOpsPullRequestForBranch(
return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null
}
export async function getAzureDevOpsRepoSlug(repoPath: string): Promise<AzureDevOpsRepoRef | null> {
return getAzureDevOpsRepoRef(repoPath)
export async function getAzureDevOpsRepoSlug(
repoPath: string,
connectionId?: string | null
): Promise<AzureDevOpsRepoRef | null> {
return getAzureDevOpsRepoRef(repoPath, connectionId)
}

View File

@ -1,7 +1,28 @@
import { describe, expect, it } from 'vitest'
import { parseAzureDevOpsRepoRef } from './repository-ref'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { sshExecMock } = vi.hoisted(() => ({
sshExecMock: vi.fn()
}))
import {
_resetAzureDevOpsRepoRefCache,
getAzureDevOpsRepoRefForRemote,
parseAzureDevOpsRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('parseAzureDevOpsRepoRef', () => {
beforeEach(() => {
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetAzureDevOpsRepoRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
_resetAzureDevOpsRepoRefCache()
})
it('parses dev.azure.com HTTPS remotes', () => {
expect(
parseAzureDevOpsRepoRef('https://dev.azure.com/acme/Project%20One/_git/repo-name')
@ -53,4 +74,43 @@ describe('parseAzureDevOpsRepoRef', () => {
it('ignores non-Azure remotes', () => {
expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull()
})
it('resolves repository refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
host: 'dev.azure.com',
organization: 'acme',
project: 'Project',
repository: 'repo',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
})
it('does not cache transient SSH provider failures as unsupported repos', async () => {
sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull()
await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
host: 'dev.azure.com',
organization: 'acme',
project: 'Project',
repository: 'repo',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo'
})
expect(sshExecMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -1,4 +1,5 @@
import { gitExecFileAsync } from '../git/runner'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
export type AzureDevOpsRepoRef = {
host: string
@ -184,25 +185,40 @@ export function parseAzureDevOpsRepoRef(remoteUrl: string): AzureDevOpsRepoRef |
export async function getAzureDevOpsRepoRefForRemote(
repoPath: string,
remoteName: string
remoteName: string,
connectionId?: string | null
): Promise<AzureDevOpsRepoRef | null> {
const cacheKey = `${repoPath}\0${remoteName}`
const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const result = parseAzureDevOpsRepoRef(stdout)
repoRefCache.set(cacheKey, result)
return result
} catch {
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Azure DevOps" would poison the repo for the session.
return null
}
repoRefCache.set(cacheKey, null)
return null
}
}
export async function getAzureDevOpsRepoRef(repoPath: string): Promise<AzureDevOpsRepoRef | null> {
return getAzureDevOpsRepoRefForRemote(repoPath, 'origin')
export async function getAzureDevOpsRepoRef(
repoPath: string,
connectionId?: string | null
): Promise<AzureDevOpsRepoRef | null> {
return getAzureDevOpsRepoRefForRemote(repoPath, 'origin', connectionId)
}

View File

@ -159,9 +159,10 @@ export async function getBitbucketAuthStatus(): Promise<BitbucketAuthStatus> {
export async function getBitbucketPullRequest(
repoPath: string,
prNumber: number
prNumber: number,
connectionId?: string | null
): Promise<BitbucketPullRequestInfo | null> {
const repo = await getBitbucketRepoRef(repoPath)
const repo = await getBitbucketRepoRef(repoPath, connectionId)
if (!repo) {
return null
}
@ -174,14 +175,15 @@ export async function getBitbucketPullRequest(
export async function getBitbucketPullRequestForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null
linkedPRNumber?: number | null,
connectionId?: string | null
): Promise<BitbucketPullRequestInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
return null
}
const repo = await getBitbucketRepoRef(repoPath)
const repo = await getBitbucketRepoRef(repoPath, connectionId)
if (!repo) {
return null
}
@ -217,6 +219,9 @@ export async function getBitbucketPullRequestForBranch(
return raw ? normalizePullRequest(repo, raw) : null
}
export async function getBitbucketRepoSlug(repoPath: string): Promise<BitbucketRepoRef | null> {
return getBitbucketRepoRef(repoPath)
export async function getBitbucketRepoSlug(
repoPath: string,
connectionId?: string | null
): Promise<BitbucketRepoRef | null> {
return getBitbucketRepoRef(repoPath, connectionId)
}

View File

@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
@ -10,16 +11,24 @@ vi.mock('../git/runner', () => ({
import {
_resetBitbucketRepoRefCache,
getBitbucketRepoRefForRemote,
getBitbucketRepoRef,
parseBitbucketRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('Bitbucket repository refs', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetBitbucketRepoRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => {
expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({
workspace: 'team',
@ -72,4 +81,37 @@ describe('Bitbucket repository refs', () => {
cwd: '/repo'
})
})
it('resolves project refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({
stdout: 'git@bitbucket.org:remote/project.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
workspace: 'remote',
repoSlug: 'project'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not cache transient SSH provider failures as unsupported repos', async () => {
sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({
stdout: 'git@bitbucket.org:remote/project.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull()
await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
workspace: 'remote',
repoSlug: 'project'
})
expect(sshExecMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})

View File

@ -1,4 +1,5 @@
import { gitExecFileAsync } from '../git/runner'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
export type BitbucketRepoRef = {
workspace: string
@ -60,25 +61,40 @@ export function parseBitbucketRepoRef(remoteUrl: string): BitbucketRepoRef | nul
export async function getBitbucketRepoRefForRemote(
repoPath: string,
remoteName: string
remoteName: string,
connectionId?: string | null
): Promise<BitbucketRepoRef | null> {
const cacheKey = `${repoPath}\0${remoteName}`
const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const result = parseBitbucketRepoRef(stdout)
repoRefCache.set(cacheKey, result)
return result
} catch {
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Bitbucket" would poison the repo for the session.
return null
}
repoRefCache.set(cacheKey, null)
return null
}
}
export async function getBitbucketRepoRef(repoPath: string): Promise<BitbucketRepoRef | null> {
return getBitbucketRepoRefForRemote(repoPath, 'origin')
export async function getBitbucketRepoRef(
repoPath: string,
connectionId?: string | null
): Promise<BitbucketRepoRef | null> {
return getBitbucketRepoRefForRemote(repoPath, 'origin', connectionId)
}

View File

@ -186,9 +186,10 @@ export async function getGiteaAuthStatus(): Promise<GiteaAuthStatus> {
export async function getGiteaPullRequest(
repoPath: string,
prNumber: number
prNumber: number,
connectionId?: string | null
): Promise<GiteaPullRequestInfo | null> {
const repo = await getGiteaRepoRef(repoPath)
const repo = await getGiteaRepoRef(repoPath, connectionId)
if (!repo) {
return null
}
@ -202,14 +203,15 @@ export async function getGiteaPullRequest(
export async function getGiteaPullRequestForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null
linkedPRNumber?: number | null,
connectionId?: string | null
): Promise<GiteaPullRequestInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
return null
}
const repo = await getGiteaRepoRef(repoPath)
const repo = await getGiteaRepoRef(repoPath, connectionId)
if (!repo) {
return null
}
@ -248,6 +250,9 @@ export async function getGiteaPullRequestForBranch(
return raw ? normalizePullRequest(repo, raw) : null
}
export async function getGiteaRepoSlug(repoPath: string): Promise<GiteaRepoRef | null> {
return getGiteaRepoRef(repoPath)
export async function getGiteaRepoSlug(
repoPath: string,
connectionId?: string | null
): Promise<GiteaRepoRef | null> {
return getGiteaRepoRef(repoPath, connectionId)
}

View File

@ -1,21 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { _resetGiteaRepoRefCache, getGiteaRepoRef, parseGiteaRepoRef } from './repository-ref'
import {
_resetGiteaRepoRefCache,
getGiteaRepoRef,
getGiteaRepoRefForRemote,
parseGiteaRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('Gitea repository ref parsing', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetGiteaRepoRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
it('parses HTTPS remotes and derives the API base URL', () => {
expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({
host: 'git.example.com',
@ -95,4 +108,39 @@ describe('Gitea repository ref parsing', () => {
cwd: '/repo'
})
})
it('resolves repository refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({
stdout: 'git@gitea.example.test:remote/project.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({
host: 'gitea.example.test',
owner: 'remote',
repo: 'project'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not cache transient SSH provider failures as unsupported repos', async () => {
sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({
stdout: 'git@gitea.example.test:remote/project.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull()
await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({
host: 'gitea.example.test',
owner: 'remote',
repo: 'project'
})
expect(sshExecMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})

View File

@ -1,4 +1,5 @@
import { gitExecFileAsync } from '../git/runner'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
export type GiteaRepoRef = {
host: string
@ -121,25 +122,40 @@ export function parseGiteaRepoRef(remoteUrl: string): GiteaRepoRef | null {
export async function getGiteaRepoRefForRemote(
repoPath: string,
remoteName: string
remoteName: string,
connectionId?: string | null
): Promise<GiteaRepoRef | null> {
const cacheKey = `${repoPath}\0${remoteName}`
const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const result = parseGiteaRepoRef(stdout)
repoRefCache.set(cacheKey, result)
return result
} catch {
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Gitea" would poison the repo for the session.
return null
}
repoRefCache.set(cacheKey, null)
return null
}
}
export async function getGiteaRepoRef(repoPath: string): Promise<GiteaRepoRef | null> {
return getGiteaRepoRefForRemote(repoPath, 'origin')
export async function getGiteaRepoRef(
repoPath: string,
connectionId?: string | null
): Promise<GiteaRepoRef | null> {
return getGiteaRepoRefForRemote(repoPath, 'origin', connectionId)
}

View File

@ -44,13 +44,13 @@ async function detectHostedReviewProvider(
if (await getRepoSlug(repoPath, connectionId)) {
return 'github'
}
if (await getBitbucketRepoSlug(repoPath)) {
if (await getBitbucketRepoSlug(repoPath, connectionId)) {
return 'bitbucket'
}
if (await getAzureDevOpsRepoSlug(repoPath)) {
if (await getAzureDevOpsRepoSlug(repoPath, connectionId)) {
return 'azure-devops'
}
if (await getGiteaRepoSlug(repoPath)) {
if (await getGiteaRepoSlug(repoPath, connectionId)) {
return 'gitea'
}
return 'unsupported'

View File

@ -174,6 +174,7 @@ describe('getHostedReviewForBranch', () => {
await expect(
getHostedReviewForBranch({
repoPath: '/repo',
connectionId: 'ssh-1',
branch: 'feature/bitbucket',
linkedBitbucketPR: 11
})
@ -188,10 +189,12 @@ describe('getHostedReviewForBranch', () => {
mergeable: 'UNKNOWN',
headSha: 'abc123'
})
expect(getBitbucketRepoSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1')
expect(getBitbucketPullRequestForBranchMock).toHaveBeenCalledWith(
'/repo',
'feature/bitbucket',
11
11,
'ssh-1'
)
})
@ -219,6 +222,7 @@ describe('getHostedReviewForBranch', () => {
await expect(
getHostedReviewForBranch({
repoPath: '/repo',
connectionId: 'ssh-1',
branch: 'feature/gitea',
linkedGiteaPR: 14
})
@ -233,7 +237,13 @@ describe('getHostedReviewForBranch', () => {
mergeable: 'MERGEABLE',
headSha: 'def456'
})
expect(getGiteaPullRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature/gitea', 14)
expect(getGiteaRepoSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1')
expect(getGiteaPullRequestForBranchMock).toHaveBeenCalledWith(
'/repo',
'feature/gitea',
14,
'ssh-1'
)
})
it('falls through to Azure DevOps before Gitea when origin is an Azure Repos remote', async () => {
@ -260,6 +270,7 @@ describe('getHostedReviewForBranch', () => {
await expect(
getHostedReviewForBranch({
repoPath: '/repo',
connectionId: 'ssh-1',
branch: 'feature/azure',
linkedAzureDevOpsPR: 21
})
@ -274,10 +285,12 @@ describe('getHostedReviewForBranch', () => {
mergeable: 'MERGEABLE',
headSha: 'abc123'
})
expect(getAzureDevOpsRepoSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1')
expect(getAzureDevOpsPullRequestForBranchMock).toHaveBeenCalledWith(
'/repo',
'feature/azure',
21
21,
'ssh-1'
)
expect(getGiteaRepoSlugMock).not.toHaveBeenCalled()
})

View File

@ -152,32 +152,35 @@ export async function getHostedReviewForBranch(input: {
return pr ? mapGitHubReview(pr) : null
}
const bitbucketRepo = await getBitbucketRepoSlug(input.repoPath)
const bitbucketRepo = await getBitbucketRepoSlug(input.repoPath, input.connectionId)
if (bitbucketRepo) {
const pr = await getBitbucketPullRequestForBranch(
input.repoPath,
branchName,
input.linkedBitbucketPR ?? null
input.linkedBitbucketPR ?? null,
input.connectionId
)
return pr ? mapBitbucketReview(pr) : null
}
const azureDevOpsRepo = await getAzureDevOpsRepoSlug(input.repoPath)
const azureDevOpsRepo = await getAzureDevOpsRepoSlug(input.repoPath, input.connectionId)
if (azureDevOpsRepo) {
const pr = await getAzureDevOpsPullRequestForBranch(
input.repoPath,
branchName,
input.linkedAzureDevOpsPR ?? null
input.linkedAzureDevOpsPR ?? null,
input.connectionId
)
return pr ? mapAzureDevOpsReview(pr) : null
}
const giteaRepo = await getGiteaRepoSlug(input.repoPath)
const giteaRepo = await getGiteaRepoSlug(input.repoPath, input.connectionId)
if (giteaRepo) {
const pr = await getGiteaPullRequestForBranch(
input.repoPath,
branchName,
input.linkedGiteaPR ?? null
input.linkedGiteaPR ?? null,
input.connectionId
)
return pr ? mapGiteaReview(pr) : null
}