Add Source Control Create PR flow (#5436)

* Add Source Control Create PR intent flow

Implements the Source Control Create PR flow described in docs/source-control-create-pr-flow.md.

* Keep Commit visible beside Create PR

* Fix Create PR partial staging action band

* Integrate hosted review creation into Create PR intent flow

- Automatically create the pull or merge request on GitHub/GitLab after
  successfully staging, committing, and pushing in the intent flow.
- Introduce a unified `updateCommitDrafts` helper to keep React state and
  its ref synchronized, preventing draft-overwrite race conditions.
- Split primary action tests into focused files to satisfy the ESLint
  `max-lines` rule.
- Replace hardcoded "Local Mac" strings with dynamic host labels.

* Support Azure DevOps and Gitea PR creation and limit large diffs

Implement automated pull request creation for Azure DevOps and Gitea
repositories. This includes REST API integration, credential checks via
environment variables, template support, and error classification.

Additionally, introduce limits on large diff payloads in git status
extraction to prevent renderer-freezing performance bottlenecks when
loading extremely large files.

* Skip source control refetches when PR creation intent is in flight

Avoid recomputing branch eligibility while isCreatePrIntentInFlight is true.
This prevents tearing down the PR composer or rotating dropdown hints
prematurely if ahead/behind or dirty states are temporarily perturbed
temporarily perturbed mid-flow.

* Expose manual prerequisite actions next to Create PR button

Previously, the Create PR intent only supported "Stage All" as a
sibling action. This expands prerequisite resolution to handle other
intermediate steps such as committing, publishing, and pushing
(including force pushing).

This ensures the edit-commit-push-review loop remains streamlined
directly within the CommitArea by displaying the specific required
next action beside the primary Create PR button.

* Move PR creation actions from CommitArea to sidebar header

- Decouples PR creation and PR intent actions from the local commit area
  primary button, ensuring local/remote git actions remain primary.
- Renders a dedicated PR creation button in the source control header
  beside the hosted review status.
- Simplifies CommitArea by removing prerequisite split-button rendering
  and review composer logic.

* Delete source control create PR flow design document

Remove the design document for the source control create PR flow as the feature has been successfully implemented.

* Display PR creation errors in inline notice

Unify PR/review creation error reporting by replacing the duplicate
createPrErrors state with the shared createPrIntentNotice. Validation
and API errors are now shown directly within the visible inline alert
notice to improve layout consistency and visibility.

Also refactor the execution host platform label lookup to use simple
if statements instead of a switch block.

* Improve Create PR intent flow safety and provider awareness

- Integrate the hosted review composer directly into the Source Control
  panel when a direct review creation action is available.
- Abort the in-flight PR creation intent flow early if the current git
  branch changes to prevent staging or committing on the wrong target.
- Keep in-flight action labels provider-aware (e.g., "Create MR" on GitLab)
  by passing hosted review inputs to the action resolver.
- Omit large diff text payloads from git status responses when line counts
  exceed safe rendering limits to avoid UI performance degradation.
- Ensure field generation does not retarget the base branch of a PR/MR without
  explicit user confirmation.

* Preserve PR and MR templates in AI pull request generation

- Preload templates (including GitLab merge requests) into the AI
  context before generation to prevent bypassing provider-side fallbacks.
- Instruct the AI generator to fill out and preserve existing template
  headings, required sections, and checklists instead of deleting them.
- Pass provider and template settings from the renderer to the backend
  RPC and runtime handlers.

* Mock DropdownMenuShortcut in tab-title-tooltip test

Add a mock for the DropdownMenuShortcut component in the dropdown menu
mock to prevent test failures.
This commit is contained in:
Jinjing
2026-06-16 15:36:27 -07:00
committed by GitHub
parent ca05dbf652
commit a300e2c7b4
64 changed files with 4161 additions and 528 deletions
+88 -2
View File
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
getAzureDevOpsRepoSlugMock,
getBitbucketRepoSlugMock,
getGiteaRepoSlugMock,
@@ -13,6 +15,8 @@ const {
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
createGitLabMergeRequestMock: vi.fn(),
createAzureDevOpsPullRequestMock: vi.fn(),
createGiteaPullRequestMock: vi.fn(),
getAzureDevOpsRepoSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
getGiteaRepoSlugMock: vi.fn(),
@@ -50,12 +54,20 @@ vi.mock('../azure-devops/client', () => ({
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../azure-devops/pull-request-creation', () => ({
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../gitea/pull-request-creation', () => ({
createGiteaPullRequest: createGiteaPullRequestMock
}))
import {
FORGE_PROVIDERS,
detectHostedReviewProvider,
@@ -67,6 +79,8 @@ describe('forge provider interface', () => {
beforeEach(() => {
createGitHubPullRequestMock.mockReset()
createGitLabMergeRequestMock.mockReset()
createAzureDevOpsPullRequestMock.mockReset()
createGiteaPullRequestMock.mockReset()
getAzureDevOpsRepoSlugMock.mockReset()
getBitbucketRepoSlugMock.mockReset()
getGiteaRepoSlugMock.mockReset()
@@ -94,8 +108,8 @@ describe('forge provider interface', () => {
['gitlab', true],
['github', true],
['bitbucket', false],
['azure-devops', false],
['gitea', false]
['azure-devops', true],
['gitea', true]
])
createGitHubPullRequestMock.mockResolvedValue({
ok: true,
@@ -160,6 +174,78 @@ describe('forge provider interface', () => {
)
})
it('routes Azure DevOps review creation through the shared provider contract', async () => {
createAzureDevOpsPullRequestMock.mockResolvedValue({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
const provider = getForgeProviderById('azure-devops')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
})
it('routes Gitea review creation through the shared provider contract', async () => {
createGiteaPullRequestMock.mockResolvedValue({
ok: true,
number: 19,
url: 'https://git.example.com/team/orca/pulls/19'
})
const provider = getForgeProviderById('gitea')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 19,
url: 'https://git.example.com/team/orca/pulls/19'
})
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'gitea',
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,
+8 -4
View File
@@ -11,6 +11,7 @@ import {
getAzureDevOpsPullRequestForBranch,
getAzureDevOpsRepoSlug
} from '../azure-devops/client'
import { createAzureDevOpsPullRequest } from '../azure-devops/pull-request-creation'
import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers'
import {
getBitbucketPullRequest,
@@ -23,6 +24,7 @@ import {
getGiteaPullRequestForBranch,
getGiteaRepoSlug
} from '../gitea/client'
import { createGiteaPullRequest } from '../gitea/pull-request-creation'
import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers'
import { createGitHubPullRequest, getPRForBranch, getRepoSlug } from '../github/client'
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
@@ -198,7 +200,7 @@ const bitbucketForgeProvider = {
const azureDevOpsForgeProvider = {
id: 'azure-devops',
supportsReviewCreation: false,
supportsReviewCreation: true,
resolveRepository: ({ repoPath, connectionId }) => getAzureDevOpsRepoSlug(repoPath, connectionId),
async getReviewForBranch(input) {
const pr = await getAzureDevOpsPullRequestForBranch(
@@ -212,12 +214,13 @@ const azureDevOpsForgeProvider = {
async getReviewByNumber(input) {
const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number, input.connectionId)
return pr ? mapAzureDevOpsReview(pr) : null
}
},
createReview: createAzureDevOpsPullRequest
} satisfies ForgeProvider
const giteaForgeProvider = {
id: 'gitea',
supportsReviewCreation: false,
supportsReviewCreation: true,
resolveRepository: ({ repoPath, connectionId }) => getGiteaRepoSlug(repoPath, connectionId),
async getReviewForBranch(input) {
const pr = await getGiteaPullRequestForBranch(
@@ -231,7 +234,8 @@ const giteaForgeProvider = {
async getReviewByNumber(input) {
const pr = await getGiteaPullRequest(input.repoPath, input.number, input.connectionId)
return pr ? mapGiteaReview(pr) : null
}
},
createReview: createGiteaPullRequest
} satisfies ForgeProvider
// Why: provider order preserves existing branch-status behavior when remotes
@@ -0,0 +1,48 @@
export class HostedReviewApiRequestError extends Error {
readonly status: number | null
readonly timedOut: boolean
constructor(message: string, options: { status?: number | null; timedOut?: boolean } = {}) {
super(message)
this.name = 'HostedReviewApiRequestError'
this.status = options.status ?? null
this.timedOut = options.timedOut ?? false
}
}
async function readResponseText(response: Response): Promise<string> {
try {
return await response.text()
} catch {
return ''
}
}
export async function requestHostedReviewJson<T>(
url: URL,
init: Omit<RequestInit, 'signal'>,
timeoutMs: number
): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, { ...init, signal: controller.signal })
if (!response.ok) {
const body = await readResponseText(response)
throw new HostedReviewApiRequestError(body || response.statusText, {
status: response.status
})
}
return (await response.json()) as T
} catch (error) {
if (error instanceof HostedReviewApiRequestError) {
throw error
}
if (error instanceof Error && error.name === 'AbortError') {
throw new HostedReviewApiRequestError('Request timed out', { timedOut: true })
}
throw error
} finally {
clearTimeout(timeout)
}
}
@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
isAzureDevOpsReviewCreationAuthenticatedMock,
isGiteaReviewCreationAuthenticatedMock,
getRepoSlugMock,
getProjectSlugMock,
getBitbucketRepoSlugMock,
@@ -18,6 +22,10 @@ const {
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
createGitLabMergeRequestMock: vi.fn(),
createAzureDevOpsPullRequestMock: vi.fn(),
createGiteaPullRequestMock: vi.fn(),
isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(),
isGiteaReviewCreationAuthenticatedMock: vi.fn(),
getRepoSlugMock: vi.fn(),
getProjectSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
@@ -59,12 +67,22 @@ vi.mock('../azure-devops/client', () => ({
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../azure-devops/pull-request-creation', () => ({
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock,
isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../gitea/pull-request-creation', () => ({
createGiteaPullRequest: createGiteaPullRequestMock,
isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock
}))
vi.mock('../github/gh-utils', () => ({
acquire: vi.fn(),
release: vi.fn(),
@@ -98,6 +116,10 @@ function resetMocks(): void {
for (const mock of [
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
isAzureDevOpsReviewCreationAuthenticatedMock,
isGiteaReviewCreationAuthenticatedMock,
getRepoSlugMock,
getProjectSlugMock,
getBitbucketRepoSlugMock,
@@ -130,6 +152,34 @@ function mockGitLabProvider(): void {
getGiteaRepoSlugMock.mockResolvedValue(null)
}
function mockAzureDevOpsProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue({
host: 'dev.azure.com',
project: 'Project',
repository: 'orca',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca'
})
getGiteaRepoSlugMock.mockResolvedValue(null)
}
function mockGiteaProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue({
host: 'git.example.com',
owner: 'acme',
repo: 'orca',
apiBaseUrl: 'https://git.example.com/api/v1',
webBaseUrl: 'https://git.example.com'
})
}
describe('createHostedReview', () => {
beforeEach(() => {
resetMocks()
@@ -169,6 +219,18 @@ describe('createHostedReview', () => {
number: 44,
url: 'https://gitlab.com/acme/orca/-/merge_requests/44'
})
createAzureDevOpsPullRequestMock.mockResolvedValue({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
createGiteaPullRequestMock.mockResolvedValue({
ok: true,
number: 19,
url: 'https://git.example.com/acme/orca/pulls/19'
})
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
})
it('revalidates ahead commits before creating a GitHub pull request', async () => {
@@ -266,6 +328,66 @@ describe('createHostedReview', () => {
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})
it('creates an Azure DevOps pull request after fresh main-process validation passes', async () => {
mockAzureDevOpsProvider()
await expect(
createHostedReview('/repo', {
provider: 'azure-devops',
base: 'main',
head: 'feature',
title: 'Feature'
})
).resolves.toEqual({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature',
title: 'Feature'
},
undefined
)
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
})
it('creates a Gitea pull request after fresh main-process validation passes', async () => {
mockGiteaProvider()
await expect(
createHostedReview('/repo', {
provider: 'gitea',
base: 'main',
head: 'feature',
title: 'Feature'
})
).resolves.toEqual({
ok: true,
number: 19,
url: 'https://git.example.com/acme/orca/pulls/19'
})
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature',
title: 'Feature'
},
undefined
)
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
})
it('uses the SSH git provider for remote hosted-review preflight', async () => {
const remoteGit = {
getStatus: vi.fn(async () => ({ entries: [], conflictOperation: 'unknown' })),
@@ -375,6 +497,8 @@ describe('getHostedReviewCreationEligibility', () => {
getHostedReviewForBranchMock.mockResolvedValue(null)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' })
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
})
it('treats short remote base refs as the default branch name', async () => {
@@ -511,4 +635,54 @@ describe('getHostedReviewCreationEligibility', () => {
{ cwd: '/repo' }
)
})
it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => {
mockAzureDevOpsProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/azure',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'azure-devops',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/azure'
})
expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => {
mockGiteaProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/gitea',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitea',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/gitea'
})
expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -12,6 +12,12 @@ import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
supportsHostedReviewCreation,
type HostedReviewCreationProvider
} from '../../shared/hosted-review-creation-providers'
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import type { GitUpstreamStatus } from '../../shared/types'
@@ -160,22 +166,56 @@ async function getHostedReviewUpstreamStatus(
function reviewCopy(provider: HostedReviewProvider): {
shortLabel: 'PR' | 'MR'
reviewLabel: 'pull request' | 'merge request'
providerName: 'GitHub' | 'GitLab'
authCommand: 'gh auth login' | 'glab auth login'
providerName: string
authInstruction: string
} {
return provider === 'gitlab'
? {
shortLabel: 'MR',
reviewLabel: 'merge request',
providerName: 'GitLab',
authCommand: 'glab auth login'
}
: {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'GitHub',
authCommand: 'gh auth login'
}
if (provider === 'gitlab') {
return {
shortLabel: 'MR',
reviewLabel: 'merge request',
providerName: 'GitLab',
authInstruction: 'Run glab auth login'
}
}
if (provider === 'azure-devops') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Azure DevOps',
authInstruction: 'Set ORCA_AZURE_DEVOPS_TOKEN'
}
}
if (provider === 'gitea') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Gitea',
authInstruction: 'Set ORCA_GITEA_TOKEN'
}
}
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'GitHub',
authInstruction: 'Run gh auth login'
}
}
async function isProviderAuthenticated(
provider: HostedReviewCreationProvider,
repoPath: string,
connectionId?: string | null
): Promise<boolean> {
if (provider === 'gitlab') {
return isGitLabAuthenticated(repoPath, connectionId)
}
if (provider === 'azure-devops') {
return isAzureDevOpsReviewCreationAuthenticated()
}
if (provider === 'gitea') {
return isGiteaReviewCreationAuthenticated()
}
return isGitHubAuthenticated(repoPath, connectionId)
}
function blockedCreateResultForReason(
@@ -187,7 +227,7 @@ function blockedCreateResultForReason(
auth_required: {
ok: false,
code: 'auth_required',
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: run ${copy.authCommand} in this environment.`
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: ${copy.authInstruction} in this environment.`
},
unsupported_provider: {
ok: false,
@@ -346,7 +386,7 @@ export async function getHostedReviewCreationEligibility(
nextAction: 'open_existing_review'
}
}
if (provider !== 'github' && provider !== 'gitlab') {
if (!supportsHostedReviewCreation(provider)) {
return {
...baseResult,
canCreate: false,
@@ -369,10 +409,7 @@ export async function getHostedReviewCreationEligibility(
if ((args.behind ?? 0) > 0) {
return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' }
}
const authenticated =
provider === 'gitlab'
? await isGitLabAuthenticated(args.repoPath, args.connectionId)
: await isGitHubAuthenticated(args.repoPath, args.connectionId)
const authenticated = await isProviderAuthenticated(provider, args.repoPath, args.connectionId)
if (!authenticated) {
return {
...baseResult,
@@ -392,7 +429,7 @@ export async function createHostedReview(
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'github' && input.provider !== 'gitlab') {
if (!supportsHostedReviewCreation(input.provider)) {
return {
ok: false,
code: 'unsupported_provider',
@@ -0,0 +1,82 @@
import { readFile } from 'fs/promises'
import { join } from 'path'
import type { HostedReviewProvider } from '../../shared/hosted-review'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
const PULL_REQUEST_TEMPLATE_CANDIDATES = [
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'.azuredevops/pull_request_template.md',
'.azuredevops/PULL_REQUEST_TEMPLATE.md',
'.gitea/pull_request_template.md',
'.gitea/PULL_REQUEST_TEMPLATE.md',
'pull_request_template.md',
'PULL_REQUEST_TEMPLATE.md',
'docs/pull_request_template.md',
'docs/PULL_REQUEST_TEMPLATE.md'
]
const MERGE_REQUEST_TEMPLATE_CANDIDATES = [
'.gitlab/merge_request_templates/Default.md',
'.gitlab/merge_request_templates/default.md',
'.gitlab/merge_request_template.md',
'.gitlab/MERGE_REQUEST_TEMPLATE.md'
]
function getTemplateCandidates(provider?: HostedReviewProvider | null): string[] {
if (provider === 'gitlab') {
return [...MERGE_REQUEST_TEMPLATE_CANDIDATES, ...PULL_REQUEST_TEMPLATE_CANDIDATES]
}
return PULL_REQUEST_TEMPLATE_CANDIDATES
}
export async function readHostedPullRequestTemplate(
repoPath: string,
connectionId?: string | null
): Promise<string> {
return readHostedReviewTemplate(repoPath, connectionId)
}
export async function readHostedReviewTemplate(
repoPath: string,
connectionId?: string | null,
provider?: HostedReviewProvider | null
): Promise<string> {
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
if (connectionId && !remoteProvider) {
return ''
}
for (const relativeCandidate of getTemplateCandidates(provider)) {
try {
if (remoteProvider) {
const result = await remoteProvider.readFile(
joinWorktreeRelativePath(repoPath, relativeCandidate)
)
if (result.isBinary) {
continue
}
return result.content
}
return await readFile(join(repoPath, relativeCandidate), 'utf8')
} catch {
// Try the next conventional hosted-review template path.
}
}
return ''
}
export async function resolveHostedReviewBodyForGeneration(args: {
body: string
repoPath: string
connectionId?: string | null
provider?: HostedReviewProvider | null
useTemplate?: boolean
}): Promise<string> {
if (!args.useTemplate || args.body.trim()) {
return args.body
}
// Why: generated non-empty bodies bypass provider-side template fallback, so
// preload the template into the AI context when the user asked to use it.
return readHostedReviewTemplate(args.repoPath, args.connectionId, args.provider)
}