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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 4161 additions and 528 deletions

View File

@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createAzureDevOpsPullRequest,
isAzureDevOpsReviewCreationAuthenticated
} from './pull-request-creation'
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
describe('Azure DevOps pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n',
stderr: ''
})
_resetAzureDevOpsRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetAzureDevOpsRepoRefCache()
})
it('treats token-only auth as sufficient for repo-scoped creation', () => {
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
expect(isAzureDevOpsReviewCreationAuthenticated()).toBe(true)
})
it('posts a pull request create body to the repository REST endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.pathname).toBe('/acme/Project/_apis/git/repositories/repo/pullRequests')
expect(url.searchParams.get('api-version')).toBe('7.1')
expect(init).toBeDefined()
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toMatch(/^Basic /)
expect(JSON.parse(String(requestInit.body))).toEqual({
sourceRefName: 'refs/heads/feature/azure',
targetRefName: 'refs/heads/main',
title: 'Add Azure create',
description: 'Body',
isDraft: true
})
return Response.json({
pullRequestId: 37,
title: 'Add Azure create',
status: 'active',
isDraft: true,
creationDate: '2026-06-01T00:00:00Z',
_links: {
web: {
href: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
}
}
})
})
globalThis.fetch = fetchMock as never
await expect(
createAzureDevOpsPullRequest('/repo', {
provider: 'azure-devops',
base: 'origin/main',
head: 'refs/heads/feature/azure',
title: 'Add Azure create',
body: 'Body',
draft: true
})
).resolves.toEqual({
ok: true,
number: 37,
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
})
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n',
stderr: ''
}))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
globalThis.fetch = vi.fn(async () =>
Response.json({
pullRequestId: 38,
title: 'Remote Azure create',
status: 'active',
creationDate: '2026-06-01T00:00:00Z'
})
) as never
await expect(
createAzureDevOpsPullRequest(
'/remote/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/azure',
title: 'Remote Azure create'
},
'ssh-1'
)
).resolves.toMatchObject({
ok: true,
number: 38
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('classifies auth failures without retrying shell commands', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ message: 'Unauthorized' }, { status: 401 })
) as never
await expect(
createAzureDevOpsPullRequest('/repo', {
provider: 'azure-devops',
base: 'main',
head: 'feature/azure',
title: 'Add Azure create'
})
).resolves.toMatchObject({
ok: false,
code: 'auth_required'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
})

View File

@ -0,0 +1,238 @@
import { Buffer } from 'buffer'
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
HostedReviewApiRequestError,
requestHostedReviewJson
} from '../source-control/hosted-review-api-request'
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
import { getAzureDevOpsPullRequestForBranch } from './client'
import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers'
import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref'
const CREATE_REQUEST_TIMEOUT_MS = 60_000
type AzureDevOpsCreateAuthConfig = {
apiBaseUrl: string | null
pat: string | null
accessToken: string | null
username: string | null
}
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
function normalizeApiBaseUrl(value: string): string {
return value
.trim()
.replace(/\/+$/, '')
.replace(/\/_apis$/i, '')
}
function getAuthConfig(): AzureDevOpsCreateAuthConfig {
return {
apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'),
pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'),
accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'),
username: envValue('ORCA_AZURE_DEVOPS_USERNAME')
}
}
export function isAzureDevOpsReviewCreationAuthenticated(): boolean {
const config = getAuthConfig()
return Boolean(config.pat || config.accessToken)
}
function authHeaders(config: AzureDevOpsCreateAuthConfig): Record<string, string> {
if (config.accessToken) {
return { Authorization: `Bearer ${config.accessToken}` }
}
if (config.pat) {
const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64')
return { Authorization: `Basic ${encoded}` }
}
return {}
}
function apiUrl(repo: AzureDevOpsRepoRef, path: string): URL {
const config = getAuthConfig()
const baseUrl = config.apiBaseUrl ? normalizeApiBaseUrl(config.apiBaseUrl) : repo.apiBaseUrl
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
url.searchParams.set('api-version', '7.1')
return url
}
function encodePathSegment(value: string): string {
return encodeURIComponent(value)
}
function azureBranchRef(branch: string): string {
return `refs/heads/${branch.replace(/^refs\/heads\//, '')}`
}
function apiErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function classifyCreateError(error: unknown): CreateHostedReviewResult {
const message = apiErrorMessage(error)
if (message) {
console.warn('createAzureDevOpsPullRequest failed:', message)
}
const lower = message.toLowerCase()
const status = error instanceof HostedReviewApiRequestError ? error.status : null
if (
status === 401 ||
status === 403 ||
lower.includes('unauthorized') ||
lower.includes('forbidden') ||
lower.includes('authentication')
) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: Azure DevOps is not authenticated. Next step: set ORCA_AZURE_DEVOPS_TOKEN in this environment.'
}
}
if (status === 409 || lower.includes('already exists') || lower.includes('active pull request')) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.'
}
}
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
}
if (status === 400 || status === 422 || lower.includes('validation')) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: Azure DevOps rejected the pull request. Check the base branch and branch state, then try again.'
}
}
return {
ok: false,
code: 'unknown',
error:
'Create PR failed: Azure DevOps could not create the pull request. Try again in a moment.'
}
}
async function findExistingPullRequest(
repoPath: string,
head: string,
connectionId?: string | null
): Promise<{ number: number; url: string } | null> {
const existing = await getAzureDevOpsPullRequestForBranch(repoPath, head, null, connectionId)
return existing ? { number: existing.number, url: existing.url } : null
}
export async function createAzureDevOpsPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'azure-devops') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
const repo = await getAzureDevOpsRepoRef(repoPath, connectionId)
if (!repo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires an Azure DevOps remote.'
}
}
const base = normalizeHostedReviewBaseRef(input.base)
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
const title = input.title.trim()
if (!base || !head || !title) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: base branch, head branch, and title are required.'
}
}
if (head.toLowerCase() === base.toLowerCase()) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: choose a different base branch before creating a pull request.'
}
}
const body =
input.useTemplate && !input.body?.trim()
? await readHostedPullRequestTemplate(repoPath, connectionId)
: (input.body ?? '')
const requestBody = {
sourceRefName: azureBranchRef(head),
targetRefName: azureBranchRef(base),
title,
description: body,
...(input.draft ? { isDraft: true } : {})
}
try {
const raw = await requestHostedReviewJson<RawAzureDevOpsPullRequest>(
apiUrl(repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`),
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders(getAuthConfig())
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
)
const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl)
if (created) {
return { ok: true, number: created.number, url: created.url }
}
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
return found
? { ok: true, ...found }
: {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
} catch (error) {
const classified = classifyCreateError(error)
if (
!classified.ok &&
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
) {
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
if (existing) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.',
existingReview: existing
}
}
}
return classified
}
}

View File

@ -1,7 +1,10 @@
/* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../shared/large-diff-render-limit'
import {
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
MAX_RENDERED_DIFF_LINES_PER_SIDE
} from '../../shared/large-diff-render-limit'
const {
gitExecFileAsyncMock,
@ -384,6 +387,33 @@ describe('getDiff', () => {
)
})
it('omits over-limit text bodies when line-count exceeds the cap', async () => {
const oversizedByLines = 'x\n'.repeat(MAX_RENDERED_DIFF_LINES_PER_SIDE)
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
statMock.mockResolvedValueOnce({
isFile: () => true,
size: oversizedByLines.length
})
readFileMock.mockResolvedValue(Buffer.from(oversizedByLines))
const result = await getDiff('/repo', 'dist/large-lines.log', false)
expect(result.kind).toBe('text')
if (result.kind !== 'text') {
throw new Error('expected text diff result')
}
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
if (result.largeDiffRenderLimit?.limited !== true) {
throw new Error('expected large diff render limit')
}
expect(result.largeDiffRenderLimit.reason).toBe('line-count')
expect(result.largeDiffRenderLimit.lineCounts?.modified).toBeGreaterThan(
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
})
it('marks git blobs that overflow maxBuffer as binary instead of pretending they are missing', async () => {
gitExecFileAsyncBufferMock.mockRejectedValueOnce(
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })

View File

@ -1072,6 +1072,8 @@ function buildDiffResult(
} as GitDiffResult
}
// Why: if the diff exceeds safe render limits, avoid sending large text
// payloads and return metadata so the renderer can show fallback UI.
const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent })
if (largeDiffRenderLimit.limited) {
return {

View File

@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation'
import { _resetGiteaRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
describe('Gitea pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://git.example.com/code/team/repo.git\n',
stderr: ''
})
_resetGiteaRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetGiteaRepoRefCache()
})
it('requires a token for repo-scoped creation', () => {
expect(isGiteaReviewCreationAuthenticated()).toBe(true)
delete process.env.ORCA_GITEA_TOKEN
expect(isGiteaReviewCreationAuthenticated()).toBe(false)
})
it('posts a pull request create body to the repository REST endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.origin).toBe('https://git.example.com')
expect(url.pathname).toBe('/code/api/v1/repos/team/repo/pulls')
expect(init).toBeDefined()
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toBe(
'token gitea-token'
)
expect(JSON.parse(String(requestInit.body))).toEqual({
base: 'main',
head: 'feature/gitea',
title: 'Add Gitea create',
body: 'Body',
draft: true
})
return Response.json({
number: 13,
title: 'Add Gitea create',
state: 'open',
draft: true,
html_url: 'https://git.example.com/code/team/repo/pulls/13',
updated_at: '2026-06-01T00:00:00Z',
mergeable: true,
head: {
ref: 'feature/gitea',
sha: 'abc123'
}
})
})
globalThis.fetch = fetchMock as never
await expect(
createGiteaPullRequest('/repo', {
provider: 'gitea',
base: 'origin/main',
head: 'refs/heads/feature/gitea',
title: 'Add Gitea create',
body: 'Body',
draft: true
})
).resolves.toEqual({
ok: true,
number: 13,
url: 'https://git.example.com/code/team/repo/pulls/13'
})
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Gitea remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
stdout: 'git@git.example.com:code/team/repo.git\n',
stderr: ''
}))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
globalThis.fetch = vi.fn(async () =>
Response.json({
number: 14,
title: 'Remote Gitea create',
state: 'open',
html_url: 'https://git.example.com/code/team/repo/pulls/14',
updated_at: '2026-06-01T00:00:00Z',
mergeable: true
})
) as never
await expect(
createGiteaPullRequest(
'/remote/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/gitea',
title: 'Remote Gitea create'
},
'ssh-1'
)
).resolves.toMatchObject({
ok: true,
number: 14
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('classifies validation failures from the REST API', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ message: 'Validation failed' }, { status: 422 })
) as never
await expect(
createGiteaPullRequest('/repo', {
provider: 'gitea',
base: 'main',
head: 'feature/gitea',
title: 'Add Gitea create'
})
).resolves.toMatchObject({
ok: false,
code: 'validation'
})
})
})

View File

@ -0,0 +1,208 @@
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
HostedReviewApiRequestError,
requestHostedReviewJson
} from '../source-control/hosted-review-api-request'
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
import { getGiteaPullRequestForBranch } from './client'
import { mapGiteaPullRequest, type RawGiteaPullRequest } from './pull-request-mappers'
import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref'
const CREATE_REQUEST_TIMEOUT_MS = 60_000
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
function normalizeApiBaseUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, '')
return /\/api\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/api/v1`
}
function configuredApiBaseUrl(repo: GiteaRepoRef): string {
const configured = envValue('ORCA_GITEA_API_BASE_URL')
return configured ? normalizeApiBaseUrl(configured) : repo.apiBaseUrl
}
export function isGiteaReviewCreationAuthenticated(): boolean {
return envValue('ORCA_GITEA_TOKEN') !== null
}
function authHeaders(): Record<string, string> {
const token = envValue('ORCA_GITEA_TOKEN')
return token ? { Authorization: `token ${token}` } : {}
}
function apiUrl(repo: GiteaRepoRef, path: string): URL {
return new URL(`${configuredApiBaseUrl(repo).replace(/\/+$/, '')}${path}`)
}
function encodedRepoPath(repo: GiteaRepoRef): string {
return `${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`
}
function apiErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function classifyCreateError(error: unknown): CreateHostedReviewResult {
const message = apiErrorMessage(error)
if (message) {
console.warn('createGiteaPullRequest failed:', message)
}
const lower = message.toLowerCase()
const status = error instanceof HostedReviewApiRequestError ? error.status : null
if (
status === 401 ||
status === 403 ||
lower.includes('unauthorized') ||
lower.includes('forbidden') ||
lower.includes('authentication')
) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: Gitea is not authenticated. Next step: set ORCA_GITEA_TOKEN in this environment.'
}
}
if (status === 409 || lower.includes('already exists') || lower.includes('already open')) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.'
}
}
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
}
if (status === 400 || status === 422 || lower.includes('validation')) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: Gitea rejected the pull request. Check the base branch and branch state, then try again.'
}
}
return {
ok: false,
code: 'unknown',
error: 'Create PR failed: Gitea could not create the pull request. Try again in a moment.'
}
}
async function findExistingPullRequest(
repoPath: string,
head: string,
connectionId?: string | null
): Promise<{ number: number; url: string } | null> {
const existing = await getGiteaPullRequestForBranch(repoPath, head, null, connectionId)
return existing ? { number: existing.number, url: existing.url } : null
}
export async function createGiteaPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'gitea') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
const repo = await getGiteaRepoRef(repoPath, connectionId)
if (!repo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires a Gitea remote.'
}
}
const base = normalizeHostedReviewBaseRef(input.base)
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
const title = input.title.trim()
if (!base || !head || !title) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: base branch, head branch, and title are required.'
}
}
if (head.toLowerCase() === base.toLowerCase()) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: choose a different base branch before creating a pull request.'
}
}
const body =
input.useTemplate && !input.body?.trim()
? await readHostedPullRequestTemplate(repoPath, connectionId)
: (input.body ?? '')
const requestBody = {
base,
head,
title,
body,
...(input.draft ? { draft: true } : {})
}
try {
const raw = await requestHostedReviewJson<RawGiteaPullRequest>(
apiUrl(repo, `/repos/${encodedRepoPath(repo)}/pulls`),
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
)
const created = mapGiteaPullRequest(raw, 'neutral')
if (created) {
return { ok: true, number: created.number, url: created.url }
}
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
return found
? { ok: true, ...found }
: {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
} catch (error) {
const classified = classifyCreateError(error)
if (
!classified.ok &&
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
) {
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
if (existing) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.',
existingReview: existing
}
}
}
return classified
}
}

View File

@ -78,6 +78,7 @@ import {
} from '../git/huge-folder-ignore'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import type { HostedReviewProvider } from '../../shared/hosted-review'
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
import { validateGitPushTarget } from '../git/push-target-validation'
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
@ -101,6 +102,7 @@ import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
import {
prepareLocalCommitMessageAgentEnv,
type CommitMessageAgentEnvironmentResolvers
@ -1130,6 +1132,8 @@ export function registerFilesystemHandlers(
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: GlobalSettings['sourceControlAi']
@ -1166,12 +1170,19 @@ export function registerFilesystemHandlers(
}
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: args.body,
repoPath: args.worktreePath,
connectionId: args.connectionId,
provider: args.provider,
useTemplate: args.useTemplate
})
context = await getPullRequestDraftContext(
(argv) => provider.exec(argv, args.worktreePath),
{
base: args.base,
currentTitle: args.title,
currentBody: args.body,
currentBody,
currentDraft: args.draft
}
)
@ -1197,12 +1208,19 @@ export function registerFilesystemHandlers(
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: args.body,
repoPath: worktreePath,
connectionId: args.connectionId,
provider: args.provider,
useTemplate: args.useTemplate
})
context = await getPullRequestDraftContext(
(argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }),
{
base: args.base,
currentTitle: args.title,
currentBody: args.body,
currentBody,
currentDraft: args.draft
}
)

View File

@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
generateCommitMessageFromContext: vi.fn(),
generatePullRequestFieldsFromContext: vi.fn(),
resolveCommitMessageSettings: vi.fn(),
resolveHostedReviewBodyForGeneration: vi.fn(),
getSshGitProvider: vi.fn()
}))
@ -53,6 +54,10 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: mocks.getSshGitProvider
}))
vi.mock('../source-control/pull-request-template', () => ({
resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration
}))
const tempDirs: string[] = []
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
@ -86,6 +91,8 @@ describe('RuntimeGitCommands', () => {
mocks.generateCommitMessageFromContext.mockReset()
mocks.generatePullRequestFieldsFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
mocks.resolveHostedReviewBodyForGeneration.mockReset()
mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body)
mocks.getSshGitProvider.mockReset()
mocks.checkoutBranch.mockReset()
mocks.listLocalBranches.mockReset()
@ -409,6 +416,69 @@ describe('RuntimeGitCommands', () => {
)
})
it('loads the hosted review template before generating pull-request fields', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const templateBody = '## Summary\n\n## Testing\n\n- [ ] Required checks'
const context = {
base: 'main',
branch: 'feature/template-aware-pr',
branchChangedByPreparation: false,
commitSummary: 'abc123 feat: test',
changeSummary: 'M README.md',
patch: '+hello',
currentTitle: '',
currentBody: templateBody,
currentDraft: false
}
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5'
}
mocks.resolveHostedReviewBodyForGeneration.mockResolvedValue(templateBody)
mocks.getPullRequestDraftContext.mockResolvedValue(context)
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({
success: true,
fields: {
base: 'main',
title: 'Use existing template',
body: templateBody,
draft: false
}
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await commands.generateRuntimePullRequestFields(
'id:wt-1',
{
base: 'main',
title: '',
body: '',
draft: false,
provider: 'gitlab',
useTemplate: true
},
{ sourceControlAiResolvedParams }
)
expect(mocks.resolveHostedReviewBodyForGeneration).toHaveBeenCalledWith({
body: '',
repoPath: worktreePath,
connectionId: undefined,
provider: 'gitlab',
useTemplate: true
})
expect(mocks.getPullRequestDraftContext).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
currentBody: templateBody
})
)
})
it('resolves remote commit-message settings against the SSH host cache', async () => {
const worktreePath = '/remote/repo'
const context = {

View File

@ -71,6 +71,8 @@ import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-mes
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
import { gitExecFileAsync } from '../git/runner'
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
import type { HostedReviewProvider } from '../../shared/hosted-review'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
@ -580,7 +582,14 @@ export class RuntimeGitCommands {
async generateRuntimePullRequestFields(
worktreeSelector: string,
input: { base: string; title: string; body: string; draft: boolean },
input: {
base: string
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
},
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GeneratePullRequestFieldsResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
@ -612,11 +621,18 @@ export class RuntimeGitCommands {
}
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: input.body,
repoPath: target.worktree.path,
connectionId: target.connectionId,
provider: input.provider,
useTemplate: input.useTemplate
})
context = target.connectionId
? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), {
base: input.base,
currentTitle: input.title,
currentBody: input.body,
currentBody,
currentDraft: input.draft
})
: await getPullRequestDraftContext(
@ -624,7 +640,7 @@ export class RuntimeGitCommands {
{
base: input.base,
currentTitle: input.title,
currentBody: input.body,
currentBody,
currentDraft: input.draft
}
)

View File

@ -172,7 +172,11 @@ export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
base: z.string().min(1, 'Missing base branch'),
title: z.string(),
body: z.string(),
draft: z.boolean()
draft: z.boolean(),
provider: z
.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported'])
.optional(),
useTemplate: z.boolean().optional()
})
export const GitBulkPaths = WorktreeSelector.extend({

View File

@ -512,13 +512,22 @@ describe('git RPC methods', () => {
title: '',
body: '',
draft: false,
provider: 'github',
useTemplate: true,
sourceControlAiResolvedParams
})
)
expect(runtime.generateRuntimePullRequestFields).toHaveBeenCalledWith(
'id:wt-1',
{ base: 'main', title: '', body: '', draft: false },
{
base: 'main',
title: '',
body: '',
draft: false,
provider: 'github',
useTemplate: true
},
{ sourceControlAiResolvedParams }
)
})

View File

@ -278,7 +278,9 @@ export const GIT_METHODS: RpcMethod[] = [
base: params.base,
title: params.title,
body: params.body,
draft: params.draft
draft: params.draft,
provider: params.provider,
useTemplate: params.useTemplate
}
const override = buildCommitMessageGenerationOverride(params)
if (override === undefined) {

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,

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

View File

@ -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)
}
}

View File

@ -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()
})
})

View File

@ -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',

View File

@ -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)
}

View File

@ -5,7 +5,8 @@ import type {
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewForBranchArgs,
HostedReviewInfo
HostedReviewInfo,
HostedReviewProvider
} from '../shared/hosted-review'
import type { NativeFileDropPayload } from '../shared/native-file-drop'
import type { AppIdentity } from '../shared/app-identity'
@ -2209,6 +2210,8 @@ export type PreloadApi = {
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: SourceControlAiSettings

View File

@ -2610,6 +2610,8 @@ const api = {
title: string
body: string
draft: boolean
provider?: unknown
useTemplate?: boolean
connectionId?: string
sourceControlAiResolvedParams?: unknown
sourceControlAi?: unknown

View File

@ -1,15 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getPaletteHostBadge } from './palette-host-badge'
import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
// Why: a connected SSH state makes the target a live remote, which is what the
// palette badge now requires before disambiguating rows with a host label.
const connectedSshStates = (targetId: string) =>
new Map([
[targetId, { targetId, status: 'connected' as const, error: null, reconnectAttempt: 0 }]
])
const localHostLabel = getLocalExecutionHostLabel()
describe('getPaletteHostBadge', () => {
it('returns null for single-host (local-only) workspaces', () => {
@ -44,7 +45,7 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({
hostId: 'local',
label: localHostLabel
label: LOCAL_HOST_LABEL
})
})
@ -114,7 +115,7 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge({}, hosts)).toEqual({
hostId: 'local',
label: localHostLabel
label: LOCAL_HOST_LABEL
})
})

View File

@ -13,7 +13,7 @@ export type PaletteHostBadge = {
// Why: Cmd+J only needs a host label when there's a live remote to disambiguate
// from. A merely-configured-but-disconnected SSH/runtime host shouldn't tag every
// row with "Local Mac", so we require an actually-reachable non-local host —
// row with the local host label, so we require an actually-reachable non-local host —
// unlike the sidebar gate, which lists disconnected hosts so users can connect.
function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean {
return hostOptions.some(

View File

@ -70,6 +70,7 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { toast } from 'sonner'
@ -694,8 +695,9 @@ export default function ChecksPanel(): React.JSX.Element {
hostedReviewCreationSnapshot?.requestKey === hostedReviewCreationRequestKey
? hostedReviewCreationSnapshot.data
: null
const hostedReviewCreateProvider: HostedReviewProvider =
hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github'
const hostedReviewCreateProvider = resolveHostedReviewCreationProvider(
hostedReviewCreation?.provider
)
const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider)
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !activeWorktree?.path) {
@ -2669,17 +2671,27 @@ export default function ChecksPanel(): React.JSX.Element {
if (activeWorktreeId && result.provider === 'gitlab') {
await updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: result.number })
}
if (activeWorktreeId && result.provider === 'azure-devops') {
await updateWorktreeMeta(activeWorktreeId, { linkedAzureDevOpsPR: result.number })
}
if (activeWorktreeId && result.provider === 'gitea') {
await updateWorktreeMeta(activeWorktreeId, { linkedGiteaPR: result.number })
}
const linkedReviewNumbers = {
linkedGitHubPR: result.provider === 'github' ? result.number : linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.provider === 'gitlab' ? result.number : linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR:
result.provider === 'azure-devops' ? result.number : linkedAzureDevOpsPR,
linkedGiteaPR: result.provider === 'gitea' ? result.number : linkedGiteaPR
}
if (result.provider === 'gitlab') {
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.number,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
...linkedReviewNumbers
})
const refreshedGitLabReview =
refreshedReview?.provider === 'gitlab' ? refreshedReview : null
@ -2690,6 +2702,15 @@ export default function ChecksPanel(): React.JSX.Element {
})
return
}
if (result.provider !== 'github') {
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
return
}
await refreshLinkedGitHubPullRequest(result.number)
} catch {
// The success toast keeps the hosted URL available; Checks can be refreshed manually.
@ -2703,6 +2724,7 @@ export default function ChecksPanel(): React.JSX.Element {
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
refreshLinkedGitHubPullRequest,
repo,

View File

@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { CommitArea, ConflictSummaryCard, OperationBanner } from './SourceControl'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
import {
resolveCommitAreaPrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items'
import { TooltipProvider } from '@/components/ui/tooltip'
@ -39,7 +42,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
primaryAction: resolveCommitAreaPrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
onGenerate: vi.fn(),
@ -50,7 +53,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
}
}
function renderCommitArea(props: ReturnType<typeof baseProps>): string {
function renderCommitArea(props: Parameters<typeof CommitArea>[0]): string {
return renderToStaticMarkup(
<TooltipProvider>
<CommitArea {...props} />
@ -327,6 +330,96 @@ describe('CommitArea', () => {
expect(button).toContain('animate-spin')
expect(button).not.toContain('lucide-check')
})
it('keeps Stage All as the commit-area primary when review prep can stage changes', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
const markup = renderCommitArea(baseProps(input))
const stageAllButton = firstButton(markup)
expect(stageAllButton).toContain('Stage All')
expect(stageAllButton).not.toContain('disabled=""')
expect(stageAllButton).toContain('lucide-plus')
expect(stageAllButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
expect(markup).toContain('Stage all changes')
expect(
(markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []).some((button) =>
button.includes('Commit</button>')
)
).toBe(false)
})
it('keeps Push as the commit-area primary when review prep can create after pushing', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
const markup = renderCommitArea(baseProps(input))
const pushButton = firstButton(markup)
expect(pushButton).toContain('Push')
expect(pushButton).not.toContain('disabled=""')
expect(pushButton).toContain('lucide-arrow-up')
expect(pushButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
})
it('hides the composer generate affordance while Create PR intent is in flight', () => {
const markup = renderCommitArea({
...baseProps(),
aiEnabled: true,
aiAgentConfigured: true,
isGenerating: true,
isCreatePrIntentInFlight: true,
createPrIntentNotice: {
tone: 'muted',
message: 'Generating commit message…'
}
})
expect(markup).not.toContain('lucide-sparkles')
expect(markup).not.toContain('animate-spin')
expect(markup).toContain('Generating commit message…')
})
it('renders Create PR failures in the visible inline notice', () => {
const markup = renderCommitArea({
...baseProps(),
createPrIntentNotice: {
tone: 'destructive',
message: 'Create PR failed: push this branch first.'
}
})
expect(markup).toContain('id="commit-area-create-pr-intent"')
expect(markup).toContain('role="alert"')
expect(markup).toContain('Create PR failed: push this branch first.')
})
})
describe('ConflictSummaryCard', () => {

View File

@ -15,6 +15,7 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
@ -68,7 +69,7 @@ export function CreatePullRequestDialog({
const submitInFlightRef = useRef(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const provider = eligibility?.provider === 'gitlab' ? 'gitlab' : 'github'
const provider = resolveHostedReviewCreationProvider(eligibility?.provider)
const copy = reviewCopy(provider)
const prCreationDefaults = React.useMemo(() => {
if (!settings) {

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
import type {
GitPushTarget,
GitStatusResult,
@ -83,3 +83,74 @@ export async function refreshGitStatusForWorktree({
runtimeTargetSettings: settings
})
}
export async function refreshGitStatusForWorktreeStrict({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget?: GitPushTarget
deps: Omit<GitStatusRefreshDeps, 'fetchUpstreamStatus'> & {
fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus']
}
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
pushTarget
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
undefined
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return { status, upstreamStatus: status.upstreamStatus }
}
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
undefined
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}

View File

@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest'
import {
createCreatePrIntentRunToken,
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentStagePaths,
resolveCreatePrIntentRemoteStep
} from './source-control-create-pr-intent-flow'
import type { GitStatusEntry } from '../../../../shared/types'
describe('source-control Create PR intent flow helpers', () => {
it('matches async completions only to the original repo, worktree, path, and branch', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
try {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature'
})
expect(token.startedAt).toBe(123)
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
} finally {
now.mockRestore()
}
})
it('matches strict git status snapshots to the original branch', () => {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature/pr'
})
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
true
)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'feature/pr' })).toBe(true)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/other' })).toBe(false)
expect(createPrIntentGitStatusMatchesToken(token, { branch: null })).toBe(false)
})
it('stages only safe unstaged and untracked paths', () => {
const unresolved = {
path: 'conflicted.ts',
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
} satisfies GitStatusEntry
expect(
getCreatePrIntentStagePaths({
unstaged: [{ path: 'safe.ts', status: 'modified', area: 'unstaged' }, unresolved],
untracked: [{ path: 'new.ts', status: 'untracked', area: 'untracked' }]
})
).toEqual(['safe.ts', 'new.ts'])
})
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
).toBe('publish')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
).toBe('push')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: {
hasUpstream: true,
ahead: 3,
behind: 2,
behindCommitsArePatchEquivalent: true
},
branchCommitsAhead: 3,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
).toBe('force_push')
})
it('blocks ordinary diverged branches and unpublished branches without commits', () => {
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 },
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
).toBe('blocked')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
).toBe('blocked')
})
})

View File

@ -0,0 +1,93 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
import { getStageAllPaths } from './discard-all-sequence'
export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none'
export type CreatePrIntentRunToken = {
repoId: string
worktreeId: string
worktreePath: string
branch: string
startedAt: number
}
export type CreatePrIntentCurrentTarget = {
repoId?: string | null
worktreeId?: string | null
worktreePath?: string | null
branch?: string | null
}
export function createCreatePrIntentRunToken(input: Omit<CreatePrIntentRunToken, 'startedAt'>) {
return { ...input, startedAt: Date.now() }
}
export function createPrIntentRunTokenMatches(
token: CreatePrIntentRunToken,
current: CreatePrIntentCurrentTarget
): boolean {
return (
token.repoId === current.repoId &&
token.worktreeId === current.worktreeId &&
token.worktreePath === current.worktreePath &&
token.branch === current.branch
)
}
export function createPrIntentGitStatusMatchesToken(
token: CreatePrIntentRunToken,
status: { branch?: string | null }
): boolean {
const branch = normalizeHostedReviewHeadRef(status.branch ?? '')
return branch.length > 0 && branch === token.branch
}
export function getCreatePrIntentStagePaths(grouped: {
unstaged: GitStatusEntry[]
untracked: GitStatusEntry[]
}): string[] {
return [
...getStageAllPaths(grouped.unstaged, 'unstaged'),
...getStageAllPaths(grouped.untracked, 'untracked')
]
}
export function resolveCreatePrIntentRemoteStep({
upstreamStatus,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch
}: {
upstreamStatus: GitUpstreamStatus | undefined
hostedReviewCreation?: HostedReviewCreationEligibility | null
branchCommitsAhead?: number
hasCurrentBranch: boolean
}): CreatePrIntentRemoteStep {
if (!hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate) {
return 'none'
}
if (hostedReviewCreation.blockedReason === 'no_upstream') {
return branchCommitsAhead && branchCommitsAhead > 0 ? 'publish' : 'blocked'
}
if (hostedReviewCreation.blockedReason === 'needs_push') {
return 'push'
}
if (
hostedReviewCreation.blockedReason === 'needs_sync' &&
shouldForcePushWithLeaseForUpstream(upstreamStatus)
) {
return 'force_push'
}
if (hostedReviewCreation.blockedReason === 'needs_sync') {
return 'blocked'
}
return 'none'
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
import type { PrimaryAction } from './source-control-primary-action-types'
const createPrIntentAction: PrimaryAction = {
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Preparing branch for review…',
disabled: true
}
const createPrAction: PrimaryAction = {
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
}
describe('resolveVisibleCreatePrHeaderAction', () => {
it('hides the header when the hosted-review composer owns direct Create PR', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrAction,
directCreatePrAction: createPrAction,
isCreatePrIntentInFlight: false,
primaryActionKind: 'create_pr'
})
).toBeNull()
})
it('hides the header while Create PR intent is in flight on the commit-area primary', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrIntentAction,
directCreatePrAction: null,
isCreatePrIntentInFlight: true,
primaryActionKind: 'create_pr_intent'
})
).toBeNull()
})
it('keeps the header visible when intent is in flight but the primary is a prerequisite action', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrIntentAction,
directCreatePrAction: null,
isCreatePrIntentInFlight: true,
primaryActionKind: 'publish'
})
).toEqual(createPrIntentAction)
})
})

View File

@ -0,0 +1,98 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
import type { GitUpstreamStatus } from '../../../../shared/types'
import type { PrimaryAction } from './source-control-primary-action-types'
export type CreatePrIntentKind =
| 'dirty'
| 'message_required'
| 'no_upstream'
| 'needs_push'
| 'force_push'
export type CreatePrIntentEligibility = {
eligible: boolean
kind: CreatePrIntentKind | null
}
export function resolveCreatePrIntentEligibility({
stagedCount,
hasStageableChanges,
hasMessage,
hasUnresolvedConflicts,
upstreamStatus,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch = true
}: {
stagedCount: number
hasStageableChanges: boolean
hasMessage: boolean
hasUnresolvedConflicts: boolean
upstreamStatus: GitUpstreamStatus | undefined
hostedReviewCreation?: HostedReviewCreationEligibility | null
branchCommitsAhead?: number
hasCurrentBranch?: boolean
}): CreatePrIntentEligibility {
if (
hasUnresolvedConflicts ||
!hasCurrentBranch ||
!hostedReviewCreation ||
hostedReviewCreation.canCreate ||
!supportsHostedReviewCreation(hostedReviewCreation.provider)
) {
return { eligible: false, kind: null }
}
if (hostedReviewCreation.blockedReason === 'dirty') {
if (stagedCount > 0 && !hasMessage) {
return { eligible: true, kind: 'message_required' }
}
return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' }
}
if (hostedReviewCreation.blockedReason === 'no_upstream') {
const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0
return {
eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges,
kind: 'no_upstream'
}
}
if (hostedReviewCreation.blockedReason === 'needs_push') {
return { eligible: true, kind: 'needs_push' }
}
if (
hostedReviewCreation.blockedReason === 'needs_sync' &&
shouldForcePushWithLeaseForUpstream(upstreamStatus)
) {
return { eligible: true, kind: 'force_push' }
}
return { eligible: false, kind: null }
}
export function resolveVisibleCreatePrHeaderAction({
createPrHeaderAction,
directCreatePrAction,
isCreatePrIntentInFlight,
primaryActionKind
}: {
createPrHeaderAction: PrimaryAction | null
directCreatePrAction: PrimaryAction | null
isCreatePrIntentInFlight: boolean
primaryActionKind: PrimaryAction['kind']
}): PrimaryAction | null {
if (directCreatePrAction) {
return null
}
// Why: CommitArea already mirrors in-flight Create PR intent on the primary;
// keeping a second spinning header button stacks redundant spinners once
// message generation also shows one.
if (isCreatePrIntentInFlight && primaryActionKind === 'create_pr_intent') {
return null
}
return createPrHeaderAction
}

View File

@ -596,6 +596,54 @@ describe('resolveDropdownItems', () => {
expect(byKind.push_create_pr.disabled).toBe(false)
})
it.each(['azure-devops', 'gitea'] as const)(
'enables push-before-PR recovery for %s review creation',
(provider) => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.label).toBe('Create PR')
expect(byKind.create_pr.hint).toBe('Push first')
expect(byKind.push_create_pr.label).toBe('Push before PR')
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
expect(byKind.push_create_pr.disabled).toBe(false)
}
)
it.each([
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'auth_required',
nextAction: 'authenticate'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.hint).toBe(hint)
})
it('uses GitLab auth copy when MR creation needs authentication', () => {
const items = resolveDropdownItems(
inputs({

View File

@ -4,6 +4,7 @@
import type { PrimaryActionInputs } from './source-control-primary-action'
import type { GitConflictOperation } from '../../../../shared/types'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
@ -96,11 +97,19 @@ function formatRebaseBaseRef(baseRef: string): string {
function reviewCopy(
provider: NonNullable<PrimaryActionInputs['hostedReviewCreation']>['provider'] | undefined
): ReturnType<typeof localizedHostedReviewCopy> & {
authCommand: 'gh auth login' | 'glab auth login'
authInstruction: string
} {
const authInstruction =
provider === 'gitlab'
? 'Run glab auth login'
: provider === 'azure-devops'
? 'Set ORCA_AZURE_DEVOPS_TOKEN'
: provider === 'gitea'
? 'Set ORCA_GITEA_TOKEN'
: 'Run gh auth login'
return {
...localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider)),
authCommand: provider === 'gitlab' ? 'glab auth login' : 'gh auth login'
authInstruction
}
}
@ -491,7 +500,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
case 'needs_sync':
return shouldForcePushWithLease ? 'Force Push first' : 'Sync first'
case 'auth_required':
return `Run ${createReviewCopy.authCommand} in this environment`
return `${createReviewCopy.authInstruction} in this environment`
case 'unsupported_provider':
return 'Unsupported provider'
case 'existing_review':
@ -521,7 +530,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
const canPushAndCreate =
!globalBusy &&
!upstreamLoading &&
(hostedReviewCreation?.provider === 'github' || hostedReviewCreation?.provider === 'gitlab') &&
supportsHostedReviewCreation(hostedReviewCreation?.provider) &&
(hostedReviewCreation.blockedReason === 'needs_push' ||
(hostedReviewCreation.blockedReason === 'needs_sync' && shouldForcePushWithLease))
const pushCreatePRItem: DropdownItem = {

View File

@ -15,6 +15,7 @@ export type PrimaryActionKind =
| 'pull'
| 'sync'
| 'publish'
| 'create_pr_intent'
| 'create_pr'
// Why: the in-flight remote op tracker stores which action the user actually
@ -65,6 +66,7 @@ export type PrimaryActionInputs = {
// Why: detached HEAD can look like an unpublished branch from upstream
// status alone, but it has no branch ref that Publish Branch can push.
hasCurrentBranch?: boolean
isPrIntentInFlight?: boolean
}
export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string> = {
@ -73,5 +75,6 @@ export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>,
pull: 'Pull',
sync: 'Sync',
publish: 'Publish Branch',
create_pr_intent: 'Create PR',
create_pr: 'Create PR'
}

View File

@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
import { resolveCreatePrHeaderAction } from './source-control-primary-create-pr-intent-action'
function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
const upstreamInSync = {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0
}
describe('resolvePrimaryAction Create PR intent', () => {
it('returns Create PR intent for an unpublished clean branch with commits to publish', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for patch-equivalent force-push before review', () => {
const result = resolvePrimaryAction(
inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for a branch that needs a safe push before review', () => {
const input = inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
const result = resolvePrimaryAction(input)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
expect(resolveCreatePrHeaderAction(input)).toEqual(result)
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Push',
title: 'Push 2 commits',
disabled: false
})
})
it('returns Create PR intent for a dirty tree when hosted review prep can commit changes', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
})
it('returns Create PR intent for staged changes without a message so the flow can request one', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create MR intent with provider copy for a GitLab dirty branch', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.label).toBe('Create MR')
expect(result.title).toBe('Prepare this branch and create a merge request')
})
it('keeps in-flight Create MR intent copy provider-aware', () => {
const input = inputs({
isPrIntentInFlight: true,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR intent for a %s branch that needs a safe push before review',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
}
)
it('separates Publish Branch from the Create PR header action for unpublished commits', () => {
const input = inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'publish',
label: 'Publish Branch',
title: 'Publish this branch to origin',
disabled: false
})
})
it('separates Force Push from the Create PR header action for patch-equivalent divergence', () => {
const input = inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Force Push',
title:
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.',
disabled: false
})
})
it('returns direct Create PR as a header action when the branch is ready', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
})
})

View File

@ -1,6 +1,9 @@
/* eslint-disable max-lines -- Why: this state-machine table intentionally keeps every primary-action priority case together so merge regressions are visible in one file. */
import { describe, expect, it } from 'vitest'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
// Why: a shared defaults object keeps each case row terse while making the
// "this is the one knob that differs from the baseline" intent obvious.
@ -380,6 +383,58 @@ describe('resolvePrimaryAction', () => {
expect(result.disabled).toBe(false)
})
it('keeps Stage All available in the commit area when Create PR intent is additive', () => {
const input = inputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes',
disabled: false
})
})
it('keeps the partial-staging reason on the additive commit-area Stage All action', () => {
const input = inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: true,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes before committing partially staged files',
disabled: false
})
})
it('still resolves to Commit when staged and unrelated unstaged files exist', () => {
const result = resolvePrimaryAction(
inputs({
@ -486,4 +541,28 @@ describe('resolvePrimaryAction', () => {
disabled: false
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR when a clean tracked %s branch is eligible for review creation',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider,
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
)
expect(result).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
}
)
})

View File

@ -14,6 +14,11 @@ import {
describePushCount,
describeSyncCounts
} from './source-control-primary-action-titles'
import {
resolveCreatePrIntentInFlightPrimaryAction,
resolveCreatePrIntentPrimaryAction
} from './source-control-primary-create-pr-intent-action'
import { resolveUnpublishedPrimaryAction } from './source-control-primary-unpublished-action'
export type {
PrimaryActionKind,
@ -34,13 +39,15 @@ export type {
* 1. In-flight commit locks the primary to a disabled "Commit".
* 2. In-flight remote operation keeps the current label but disables it.
* 3. Unresolved conflicts block the commit path entirely.
* 4. Has partially staged files "Stage All" to avoid hook-time partial
* 4. Create PR intent can own the primary; manual prerequisites are
* exposed as a visible sibling action by CommitArea.
* 5. Has partially staged files "Stage All" to avoid hook-time partial
* stash conflicts.
* 5. Has staged files + message plain "Commit" (compound flows live in
* the dropdown; after the commit lands, step 7 rotates the primary to
* the appropriate single remote action).
* 6. Has staged files + no message disabled "Commit" with a reason.
* 7. Clean tree adaptive remote action (or disabled "Commit" no-op).
* 6. Has staged files + message plain "Commit" (compound flows live in
* the dropdown; after the commit lands, the clean-tree rung rotates
* the primary to the appropriate single remote action).
* 7. Has staged files + no message disabled "Commit" with a reason.
* 8. Clean tree adaptive remote action (or disabled "Commit" no-op).
*
* An undefined upstream status means fetchUpstreamStatus has not resolved
* yet for this worktree. We return a disabled Commit so the button has a
@ -62,9 +69,14 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
isPRStateLoading,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch = true
hasCurrentBranch = true,
isPrIntentInFlight = false
} = inputs
if (isPrIntentInFlight) {
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
}
// 1. Commit in flight — lock the primary no matter what else is true.
if (isCommitting) {
return {
@ -101,11 +113,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
}
const createPrIntent = resolveCreatePrIntentPrimaryAction(inputs)
if (createPrIntent) {
return createPrIntent
}
const hasStaged = stagedCount > 0
// 4. A path with both staged and unstaged edits can make lint-staged's
// partial-stash restore fail after formatters rewrite the staged copy. Push
// the user through Stage All first so the index matches the worktree.
// Why: partial staging can break hook-time restores during the intent flow;
// keep Stage All visible as a sibling prerequisite without replacing Create PR.
if (hasStaged && hasPartiallyStagedChanges) {
return {
kind: 'stage',
@ -194,78 +210,12 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
if (!upstreamStatus.hasUpstream) {
if (!hasCurrentBranch) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
'Check out a branch before publishing commits.'
),
disabled: true
}
}
if (branchCommitsAhead === 0) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.acce237921',
'Nothing to commit. Branch has no changes to publish.'
),
disabled: true
}
}
if (isPRStateLoading) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
'Checking PR status…'
),
disabled: true
}
}
if (prState === 'merged') {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
'Nothing to commit. PR is already merged.'
),
disabled: true
}
}
return {
kind: 'publish',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
'Publish Branch'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
'Publish this branch to origin'
),
disabled: false
}
return resolveUnpublishedPrimaryAction({
hasCurrentBranch,
branchCommitsAhead,
isPRStateLoading,
prState
})
}
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
@ -347,3 +297,13 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
disabled: true
}
}
export function resolveCommitAreaPrimaryAction(inputs: PrimaryActionInputs): PrimaryAction {
// Why: review creation is additive chrome. The commit area should keep the
// same local/remote primary action it would have without review eligibility.
return resolvePrimaryAction({
...inputs,
hostedReviewCreation: null,
isPrIntentInFlight: false
})
}

View File

@ -0,0 +1,96 @@
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
resolveSupportedHostedReviewCopyProvider
} from '@/i18n/hosted-review-localized-copy'
import type { PrimaryAction, PrimaryActionInputs } from './source-control-primary-action-types'
import { resolveCreatePrIntentEligibility } from './source-control-create-pr-intent-state'
export function resolveCreatePrIntentInFlightPrimaryAction(
inputs?: Pick<PrimaryActionInputs, 'hostedReviewCreation'>
): PrimaryAction {
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs?.hostedReviewCreation?.provider)
)
return {
kind: 'create_pr_intent',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.d37e68f61d',
'Preparing branch for review…'
),
disabled: true
}
}
export function resolveCreatePrIntentPrimaryAction(
inputs: PrimaryActionInputs
): PrimaryAction | null {
const createPrIntent = resolveCreatePrIntentEligibility({
stagedCount: inputs.stagedCount,
hasStageableChanges: inputs.hasStageableChanges,
hasMessage: inputs.hasMessage,
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
upstreamStatus: inputs.upstreamStatus,
hostedReviewCreation: inputs.hostedReviewCreation,
branchCommitsAhead: inputs.branchCommitsAhead,
hasCurrentBranch: inputs.hasCurrentBranch
})
if (!createPrIntent.eligible) {
return null
}
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider)
)
return {
kind: 'create_pr_intent',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.c72e5e65d1',
'Prepare this branch and create a {{value0}}',
{ value0: copy.reviewLabel }
),
disabled: false
}
}
export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): PrimaryAction | null {
if (inputs.isPrIntentInFlight) {
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
}
if (inputs.isCommitting || inputs.isRemoteOperationActive || inputs.hasUnresolvedConflicts) {
return null
}
if (inputs.hostedReviewCreation?.canCreate) {
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation.provider)
)
return {
kind: 'create_pr',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.946a8a05ea',
'Create a {{value0}} for this branch',
{ value0: copy.reviewLabel }
),
disabled: false
}
}
return resolveCreatePrIntentPrimaryAction(inputs)
}

View File

@ -0,0 +1,88 @@
import { translate } from '@/i18n/i18n'
import type { PrimaryAction } from './source-control-primary-action-types'
import type { PRState } from '../../../../shared/types'
export function resolveUnpublishedPrimaryAction({
hasCurrentBranch,
branchCommitsAhead,
isPRStateLoading,
prState
}: {
hasCurrentBranch: boolean
branchCommitsAhead?: number
isPRStateLoading?: boolean
prState?: PRState | null
}): PrimaryAction {
if (!hasCurrentBranch) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
'Check out a branch before publishing commits.'
),
disabled: true
}
}
if (branchCommitsAhead === 0) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.acce237921',
'Nothing to commit. Branch has no changes to publish.'
),
disabled: true
}
}
if (isPRStateLoading) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
'Checking PR status…'
),
disabled: true
}
}
if (prState === 'merged') {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
'Nothing to commit. PR is already merged.'
),
disabled: true
}
}
return {
kind: 'publish',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
'Publish Branch'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
'Publish this branch to origin'
),
disabled: false
}
}

View File

@ -369,7 +369,9 @@ export function useCreatePullRequestDialogFields({
base: stripBaseRef(base.trim()),
title,
body,
draft
draft,
provider: eligibility?.provider,
useTemplate: resolvedPrDefaults.useTemplate
},
overrides
)
@ -417,9 +419,11 @@ export function useCreatePullRequestDialogFields({
draft,
effectiveGenerating,
applyGeneratedFields,
eligibility?.provider,
generation,
generateDisabled,
onBranchChangedByGeneration,
resolvedPrDefaults.useTemplate,
settings,
title,
worktreeId,

View File

@ -3,7 +3,7 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
RUNTIME_PROTOCOL_VERSION,
@ -15,7 +15,8 @@ import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection'
let container: HTMLDivElement
let root: Root
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'displayName' | 'path'>): Repo {
return {
@ -143,7 +144,7 @@ describe('RepositoryHostSetupsSection', () => {
renderSection(localRepo)
expect(container.textContent).toContain('Viewing host')
expect(container.textContent).toContain(localHostLabel)
expect(container.textContent).toContain(LOCAL_HOST_LABEL)
})
it('opens the selected host setup settings pane through the setup repo id', () => {

View File

@ -3,12 +3,14 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
GitHubIntegrationCard,
GitLabIntegrationCard
} from './cli-source-control-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
@ -48,7 +50,6 @@ vi.mock('./source-control-preflight-card-status', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
const localHostLabel = getLocalExecutionHostLabel()
async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> {
container = document.createElement('div')
@ -90,7 +91,7 @@ describe('CLI source-control integration card account scope', () => {
expect(rendered.textContent).toContain('GitHub')
expect(rendered.textContent).toContain('Connected')
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
)

View File

@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderAccountScope, getProviderRateLimitScope } from './provider-account-scope'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('getProviderAccountScope', () => {
it('describes provider accounts as client-owned without an active runtime', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
description:
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
})
@ -21,7 +23,7 @@ describe('getProviderAccountScope', () => {
it('describes provider API budgets as host-scoped', () => {
expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
description:
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.'
})

View File

@ -1,8 +1,10 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { GitHubRateLimitPanel } from '@/components/github/github-rate-limit-display'
import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-display'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
@ -19,7 +21,6 @@ const mocks = vi.hoisted(() => ({
} as StoreState
}
}))
const localHostLabel = getLocalExecutionHostLabel()
vi.mock('@/store', () => ({
useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.store.current)
@ -35,7 +36,7 @@ describe('provider rate-limit panels account scope', () => {
const markup = renderToStaticMarkup(<GitHubRateLimitPanel />)
expect(markup).toContain(`Budget scope: ${localHostLabel}`)
expect(markup).toContain(`Budget scope: ${LOCAL_HOST_LABEL}`)
expect(markup).toContain(
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings &gt; Remote Orca Servers &gt; Advanced to view server-owned budgets.'
)

View File

@ -3,10 +3,12 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
linearStatus: {
connected: boolean
@ -46,7 +48,6 @@ vi.mock('@/components/linear-api-key-dialog', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
const localHostLabel = getLocalExecutionHostLabel()
function installStore(
connected: boolean,
@ -108,7 +109,7 @@ describe('LinearIntegrationCard account scope', () => {
const rendered = await renderCard()
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
)

View File

@ -571,8 +571,8 @@ function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boo
isWarning: false
}
}
// Why: the transport suffix only earns space on remote hosts; "This
// computer" on Local Mac is noise.
// Why: the transport suffix only earns space on remote hosts; repeating
// "This computer" under the local host label is noise.
if (row.kind !== 'local') {
return { text: row.detail, isWarning: false }
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
buildSidebarHostOptions,
buildSidebarHostScopeOptions,
@ -8,6 +8,8 @@ import {
shouldShowHostScopeControls
} from './sidebar-host-options'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('sidebar host options', () => {
it('hides host controls for local-only workspaces', () => {
const hosts = buildSidebarHostOptions({
@ -19,7 +21,7 @@ describe('sidebar host options', () => {
expect(hosts).toEqual([
{
id: 'local',
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
detail: 'This computer',
kind: 'local',
health: 'local',
@ -174,13 +176,8 @@ describe('sidebar host options', () => {
})
expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([
{
id: 'all',
label: 'All hosts',
detail: `${getLocalExecutionHostLabel()}, Builder`,
health: 'mixed'
},
{ id: 'local', label: getLocalExecutionHostLabel(), health: 'local' },
{ id: 'all', label: 'All hosts', detail: `${LOCAL_HOST_LABEL}, Builder`, health: 'mixed' },
{ id: 'local', label: LOCAL_HOST_LABEL, health: 'local' },
{ id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' }
])
})

View File

@ -2,7 +2,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
ALL_GROUP_META,
buildRows,
@ -24,7 +24,7 @@ import type {
WorktreeLineage
} from '../../../../shared/types'
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
const repo: Repo = {
id: 'repo-1',
@ -349,7 +349,7 @@ describe('buildRows with pinned worktrees', () => {
expect(rows).toMatchObject([
{ type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: localHostLabel },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ type: 'item', worktree: { id: remoteWorktree.id }, hostContextLabel: 'gpu-vm' }
])
})
@ -464,14 +464,14 @@ describe('buildRows with pinned worktrees', () => {
{ projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] },
[],
new Map([
['local', localHostLabel],
['local', LOCAL_HOST_LABEL],
['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']
])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: localHostLabel },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev box' }
])
})

View File

@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../shared/execution-host'
import {
getTaskSourceAvailabilityNotice,
getTaskSourceContextSummary
} from './task-source-context-summary'
import { getExecutionHostLabel } from '../../../shared/execution-host'
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('task source context summary', () => {
it('shows provider, host, and provider identity for a single repo-backed source', () => {
@ -59,9 +59,9 @@ describe('task source context summary', () => {
]
})
expect(summary.label).toBe(`GitHub · ${localHostLabel}, builder · personal-gh, work-gh`)
expect(summary.label).toBe(`GitHub · ${LOCAL_HOST_LABEL}, builder · personal-gh, work-gh`)
expect(summary.title).toBe(
`GitHub · Host: ${localHostLabel}, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects`
`GitHub · Host: ${LOCAL_HOST_LABEL}, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects`
)
})
@ -152,9 +152,9 @@ describe('task source context summary', () => {
]
})
expect(summary.label).toBe(`GitLab · ${localHostLabel} +2 · 3 projects`)
expect(summary.label).toBe(`GitLab · ${LOCAL_HOST_LABEL} +2 · 3 projects`)
expect(summary.title).toBe(
`GitLab · Host: ${localHostLabel}, build, linux · 3 selected projects`
`GitLab · Host: ${LOCAL_HOST_LABEL}, build, linux · 3 selected projects`
)
})
@ -280,7 +280,7 @@ describe('task source context summary', () => {
accountHostId: 'local',
linearWorkspaceName: 'Stably'
}).label
).toBe(`Linear · ${localHostLabel} · Stably`)
).toBe(`Linear · ${LOCAL_HOST_LABEL} · Stably`)
expect(
getTaskSourceContextSummary({

View File

@ -1,7 +1,11 @@
import type { HostedReviewProvider } from '../../../shared/hosted-review'
import {
resolveHostedReviewCreationProvider,
type HostedReviewCreationProvider
} from '../../../shared/hosted-review-creation-providers'
import { translate } from '@/i18n/i18n'
export type SupportedHostedReviewCopyProvider = 'github' | 'gitlab'
export type SupportedHostedReviewCopyProvider = HostedReviewCreationProvider
export type LocalizedHostedReviewCopy = {
shortLabel: string
@ -13,7 +17,7 @@ export type LocalizedHostedReviewCopy = {
export function resolveSupportedHostedReviewCopyProvider(
provider: HostedReviewProvider | null | undefined
): SupportedHostedReviewCopyProvider {
return provider === 'gitlab' ? 'gitlab' : 'github'
return resolveHostedReviewCreationProvider(provider)
}
export function localizedHostedReviewCopy(
@ -27,6 +31,22 @@ export function localizedHostedReviewCopy(
providerName: translate('auto.i18n.hostedReview.copy.91b5c8d7e6', 'GitLab')
}
}
if (provider === 'azure-devops') {
return {
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
titleLabel: translate('auto.i18n.hostedReview.copy.d8e2f6a0b9', 'Pull Request'),
providerName: 'Azure DevOps'
}
}
if (provider === 'gitea') {
return {
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
titleLabel: translate('auto.i18n.hostedReview.copy.d8e2f6a0b9', 'Pull Request'),
providerName: 'Gitea'
}
}
return {
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),

View File

@ -8366,14 +8366,32 @@
"e2b7a1c0d9f4": "Failed to create {{value0}}",
"hugeRepoIgnorePrompt": "This repository has too many active changes. Add \"{{value0}}\" to .gitignore?",
"hugeRepoIgnoreAction": "Add to .gitignore",
"tooManyChanges": "Too many changes detected. Only the first {{value0}} are shown.",
"tooManyChanges": "Too many changes detected. Only the first {{value0}} changes are shown.",
"bf5082de46": "{{value0}} copied",
"c06193ef57": "Failed to copy {{value0}}",
"d172a4f068": "Commit hash",
"e283b50179": "Commit message",
"f394c6128a": "No agent available to explain this commit",
"04a5d7239b": "This repository has no supported web remote",
"15b6e834ac": "Failed to open commit in browser"
"15b6e834ac": "Failed to open commit in browser",
"d37e68f61d": "Preparing branch for review…",
"8d8f5c6c94": "Generating commit message…",
"fda060d6ce": "Review the commit message, then retry Create PR.",
"b75cb1fd0c": "Committing changes…",
"995c5e67ec": "Review setup needs attention.",
"d7492cafce": "Could not refresh Source Control. Retry Create PR.",
"473f18758e": "Source Control AI settings",
"createPrIntentConfigureAi": "Add a commit message or configure Source Control AI settings.",
"createPrIntentGenerateFailed": "Could not generate a commit message. Add one and retry.",
"createPrIntentCommitFailed": "Could not commit changes. Fix the issue, then retry Create PR.",
"createPrIntentNeedsSync": "Sync this branch before creating a review.",
"createPrIntentBranchNotReady": "Branch is not ready to create a review yet.",
"createPrIntentPublishing": "Publishing branch…",
"createPrIntentForcePushing": "Force pushing with lease…",
"createPrIntentPushing": "Pushing commits…",
"createPrIntentRemoteFailed": "Could not update the remote branch. Retry Create PR.",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "Could not start the selected agent.",
@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} in progress…",
"74fc171e99": "Force Push in progress…",
"16aee3a5c1": "Commit in progress…",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "Check out a branch before publishing commits.",
"8c6d15a07d": "Create PR",
"d37e68f61d": "Preparing branch for review…",
"c72e5e65d1": "Prepare this branch and create a {{value0}}"
}
}
}

View File

@ -8373,7 +8373,25 @@
"e283b50179": "Mensaje del commit",
"f394c6128a": "No hay ningún agente disponible para explicar este commit",
"04a5d7239b": "Este repositorio no tiene un remoto web compatible",
"15b6e834ac": "No se pudo abrir el commit en el navegador"
"15b6e834ac": "No se pudo abrir el commit en el navegador",
"d37e68f61d": "Preparando la rama para revisión…",
"8d8f5c6c94": "Generando mensaje de commit…",
"fda060d6ce": "Revisa el mensaje de commit y vuelve a intentar Crear PR.",
"b75cb1fd0c": "Confirmando cambios…",
"995c5e67ec": "La configuración de revisión necesita atención.",
"d7492cafce": "No se pudo actualizar Source Control. Vuelve a intentar Crear PR.",
"473f18758e": "Configuración de IA de Source Control",
"createPrIntentConfigureAi": "Agrega un mensaje de commit o configura la IA de Source Control.",
"createPrIntentGenerateFailed": "No se pudo generar un mensaje de commit. Agrega uno y vuelve a intentarlo.",
"createPrIntentCommitFailed": "No se pudieron confirmar los cambios. Corrige el problema y vuelve a intentar Crear PR.",
"createPrIntentNeedsSync": "Sincroniza esta rama antes de crear una revisión.",
"createPrIntentBranchNotReady": "La rama aún no está lista para crear una revisión.",
"createPrIntentPublishing": "Publicando rama…",
"createPrIntentForcePushing": "Haciendo force push con lease…",
"createPrIntentPushing": "Subiendo commits…",
"createPrIntentRemoteFailed": "No se pudo actualizar la rama remota. Vuelve a intentar Crear PR.",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} en progreso…",
"74fc171e99": "Empuje forzado en progreso...",
"16aee3a5c1": "Compromiso en progreso...",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "Cambia a una rama antes de publicar commits.",
"8c6d15a07d": "Crear PR",
"d37e68f61d": "Preparando la rama para revisión…",
"c72e5e65d1": "Prepara esta rama y crea un {{value0}}"
}
}
}

View File

@ -8373,7 +8373,25 @@
"e283b50179": "コミットメッセージ",
"f394c6128a": "このコミットを説明できるエージェントがありません",
"04a5d7239b": "このリポジトリには対応するWebリモートがありません",
"15b6e834ac": "コミットをブラウザーで開けませんでした"
"15b6e834ac": "コミットをブラウザーで開けませんでした",
"d37e68f61d": "レビュー用にブランチを準備中…",
"8d8f5c6c94": "コミットメッセージを生成中…",
"fda060d6ce": "コミットメッセージを確認してから、Create PR を再試行してください。",
"b75cb1fd0c": "変更をコミット中…",
"995c5e67ec": "レビュー設定の確認が必要です。",
"d7492cafce": "Source Control を更新できませんでした。Create PR を再試行してください。",
"473f18758e": "Source Control AI 設定",
"createPrIntentConfigureAi": "コミットメッセージを追加するか、Source Control AI 設定を構成してください。",
"createPrIntentGenerateFailed": "コミットメッセージを生成できませんでした。追加してから再試行してください。",
"createPrIntentCommitFailed": "変更をコミットできませんでした。問題を修正してから Create PR を再試行してください。",
"createPrIntentNeedsSync": "レビューを作成する前にこのブランチを同期してください。",
"createPrIntentBranchNotReady": "このブランチはまだレビューを作成できる状態ではありません。",
"createPrIntentPublishing": "ブランチを公開中…",
"createPrIntentForcePushing": "lease 付きで強制プッシュ中…",
"createPrIntentPushing": "コミットをプッシュ中…",
"createPrIntentRemoteFailed": "リモートブランチを更新できませんでした。Create PR を再試行してください。",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "選択した agent を開始できませんでした。",
@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} が進行中です…",
"74fc171e99": "強制プッシュ中です…",
"16aee3a5c1": "Commit 中です…",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "コミットを公開する前にブランチをチェックアウトしてください。",
"8c6d15a07d": "PR を作成",
"d37e68f61d": "レビュー用にブランチを準備中…",
"c72e5e65d1": "このブランチを準備して {{value0}} を作成"
}
}
}

View File

@ -8373,7 +8373,25 @@
"e283b50179": "커밋 메시지",
"f394c6128a": "이 커밋을 설명할 에이전트가 없습니다",
"04a5d7239b": "이 저장소에는 지원되는 웹 원격이 없습니다",
"15b6e834ac": "브라우저에서 커밋을 열지 못했습니다"
"15b6e834ac": "브라우저에서 커밋을 열지 못했습니다",
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
"8d8f5c6c94": "커밋 메시지를 생성하는 중…",
"fda060d6ce": "커밋 메시지를 검토한 다음 Create PR을 다시 시도하세요.",
"b75cb1fd0c": "변경 사항을 커밋하는 중…",
"995c5e67ec": "검토 설정에 확인이 필요합니다.",
"d7492cafce": "Source Control을 새로 고칠 수 없습니다. Create PR을 다시 시도하세요.",
"473f18758e": "Source Control AI 설정",
"createPrIntentConfigureAi": "커밋 메시지를 추가하거나 Source Control AI 설정을 구성하세요.",
"createPrIntentGenerateFailed": "커밋 메시지를 생성할 수 없습니다. 메시지를 추가한 다음 다시 시도하세요.",
"createPrIntentCommitFailed": "변경 사항을 커밋할 수 없습니다. 문제를 수정한 다음 Create PR을 다시 시도하세요.",
"createPrIntentNeedsSync": "리뷰를 만들기 전에 이 브랜치를 동기화하세요.",
"createPrIntentBranchNotReady": "브랜치가 아직 리뷰를 만들 준비가 되지 않았습니다.",
"createPrIntentPublishing": "브랜치를 게시하는 중…",
"createPrIntentForcePushing": "lease로 강제 푸시하는 중…",
"createPrIntentPushing": "커밋을 푸시하는 중…",
"createPrIntentRemoteFailed": "원격 브랜치를 업데이트할 수 없습니다. Create PR을 다시 시도하세요.",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} 진행 중…",
"74fc171e99": "강제 푸시 진행 중…",
"16aee3a5c1": "Commit 진행 중…",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "커밋을 게시하기 전에 브랜치를 체크아웃하세요.",
"8c6d15a07d": "PR 생성",
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
"c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성"
}
}
}

View File

@ -8373,7 +8373,25 @@
"e283b50179": "提交信息",
"f394c6128a": "没有可用于解释此提交的代理",
"04a5d7239b": "此仓库没有受支持的网页远程库",
"15b6e834ac": "无法在浏览器中打开提交"
"15b6e834ac": "无法在浏览器中打开提交",
"d37e68f61d": "正在准备分支以供评审…",
"8d8f5c6c94": "正在生成 commit 消息…",
"fda060d6ce": "请先检查 commit 消息,然后重试创建 PR。",
"b75cb1fd0c": "正在提交更改…",
"995c5e67ec": "评审设置需要处理。",
"d7492cafce": "无法刷新 Source Control。请重试创建 PR。",
"473f18758e": "Source Control AI 设置",
"createPrIntentConfigureAi": "请添加 commit 消息或配置 Source Control AI 设置。",
"createPrIntentGenerateFailed": "无法生成 commit 消息。请添加一条消息后重试。",
"createPrIntentCommitFailed": "无法提交更改。请修复问题后重试创建 PR。",
"createPrIntentNeedsSync": "创建评审前请先同步此分支。",
"createPrIntentBranchNotReady": "分支尚未准备好创建评审。",
"createPrIntentPublishing": "正在发布分支…",
"createPrIntentForcePushing": "正在使用 lease 强制推送…",
"createPrIntentPushing": "正在推送 commits…",
"createPrIntentRemoteFailed": "无法更新远程分支。请重试创建 PR。",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "无法启动选定的 Agent。",
@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} 正在进行中...",
"74fc171e99": "强制推送正在进行中...",
"16aee3a5c1": "正在进行中……",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "请先检出分支再发布 commits。",
"8c6d15a07d": "创建 PR",
"d37e68f61d": "正在准备分支以供评审…",
"c72e5e65d1": "准备此分支并创建 {{value0}}"
}
}
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host'
import { getExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
@ -12,7 +12,8 @@ const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
]
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
function repo(id: string): Repo {
return {
@ -53,7 +54,7 @@ function host(
return {
id,
kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime',
label: id === 'local' ? localHostLabel : id.replace(/^ssh:|^runtime:/, ''),
label: id === 'local' ? LOCAL_HOST_LABEL : id.replace(/^ssh:|^runtime:/, ''),
detail: id === 'local' ? 'This computer' : 'Host',
health: id === 'local' ? 'local' : 'available',
...overrides
@ -72,7 +73,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options.map((option) => option.id)).toEqual(['local', 'remote'])
expect(options[0]).toMatchObject({ label: localHostLabel, repoId: 'local-repo' })
expect(options[0]).toMatchObject({ label: LOCAL_HOST_LABEL, repoId: 'local-repo' })
expect(options[1]).toMatchObject({ label: 'builder', repoId: 'remote-repo' })
})
@ -131,7 +132,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
expect.objectContaining({
id: 'needs-setup:ssh:builder',
kind: 'needs-setup',
@ -164,7 +165,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
expect.objectContaining({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
@ -259,7 +260,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
expect.objectContaining({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',

View File

@ -13,7 +13,7 @@ export type SourceControlGenerationPlanResult =
const SYNTHETIC_COMMIT_PROMPT =
'Generate a concise git commit message for a synthetic dry-run diff. Return only the commit message.'
const SYNTHETIC_PULL_REQUEST_PROMPT =
'Generate a hosted review title and description for a synthetic branch diff. Return structured pull request fields.'
'Generate a hosted review title and description for a synthetic branch diff. Preserve any existing pull request or merge request template in the current description. Return structured pull request fields.'
const SYNTHETIC_TEXT_GENERATION_CONTEXT: Record<
SourceControlTextActionId,

View File

@ -17,6 +17,7 @@ import type {
CommitMessageAgentCapability,
CommitMessageModelCapability
} from '../../../shared/commit-message-agent-spec'
import type { HostedReviewProvider } from '../../../shared/hosted-review'
import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history'
@ -37,6 +38,15 @@ export type RuntimeGeneratePullRequestFieldsResult =
}
| { success: false; error: string; canceled?: boolean; branchChangedByPreparation?: boolean }
export type RuntimePullRequestGenerationInput = {
base: string
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
}
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
Partial<
Pick<
@ -608,7 +618,7 @@ export async function cancelRuntimeGenerateCommitMessage(
export async function generateRuntimePullRequestFields(
context: RuntimeGitContext,
input: { base: string; title: string; body: string; draft: boolean },
input: RuntimePullRequestGenerationInput,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<RuntimeGeneratePullRequestFieldsResult> {
const target = getActiveRuntimeTarget(context.settings)

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import { getExecutionHostLabel } from './execution-host'
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
import { getLocalExecutionHostLabel } from './execution-host'
import { buildExecutionHostRegistry } from './execution-host-registry'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('execution host registry', () => {
it('returns only the local host for local-only state', () => {
expect(
@ -14,7 +16,7 @@ describe('execution host registry', () => {
{
id: 'local',
kind: 'local',
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
detail: 'This computer',
health: 'local'
}
@ -189,7 +191,7 @@ describe('execution host registry', () => {
})
expect(hosts).toMatchObject([
{ id: 'local', label: getLocalExecutionHostLabel() },
{ id: 'local', label: LOCAL_HOST_LABEL },
{ id: 'ssh:repo-ssh', label: 'Derived SSH' }
])
})

View File

@ -71,4 +71,10 @@ describe('execution host identity', () => {
'runtime:runtime-1'
)
})
it('labels local execution hosts by platform', () => {
expect(getLocalExecutionHostLabel('darwin')).toBe('Local Mac')
expect(getLocalExecutionHostLabel('linux')).toBe('Local Linux')
expect(getLocalExecutionHostLabel('win32')).toBe('Local Windows')
})
})

View File

@ -13,42 +13,41 @@ export type ParsedExecutionHost =
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
function getCurrentLocalPlatform(): NodeJS.Platform | null {
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
.navigator
const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''
if (/Windows/i.test(userAgent)) {
return 'win32'
}
if (/Mac/i.test(userAgent)) {
return 'darwin'
}
if (/Linux|X11/i.test(userAgent)) {
return 'linux'
}
return typeof process === 'undefined' ? null : process.platform
}
export function getLocalExecutionHostLabel(platform: NodeJS.Platform | null = null): string {
const localPlatform = platform ?? getCurrentLocalPlatform()
if (localPlatform === 'darwin') {
return 'Local Mac'
}
if (localPlatform === 'win32') {
return 'Local Windows'
}
if (localPlatform === 'linux') {
return 'Local Linux'
}
return 'This computer'
}
function normalizeHostPart(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function getCurrentHostPlatform(): string {
if (typeof process !== 'undefined' && typeof process.platform === 'string') {
return process.platform
}
if (typeof navigator !== 'undefined') {
if (navigator.userAgent.includes('Windows')) {
return 'win32'
}
if (navigator.userAgent.includes('Linux')) {
return 'linux'
}
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
}
}
return ''
}
export function getLocalExecutionHostLabel(platform = getCurrentHostPlatform()): string {
switch (platform) {
case 'darwin':
return 'Local Mac'
case 'win32':
return 'Local Windows'
case 'linux':
return 'Local Linux'
default:
return 'This computer'
}
}
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
return `ssh:${encodeURIComponent(targetId)}`
}

View File

@ -0,0 +1,20 @@
import type { HostedReviewProvider } from './hosted-review'
export type HostedReviewCreationProvider = 'github' | 'gitlab' | 'azure-devops' | 'gitea'
export function supportsHostedReviewCreation(
provider: HostedReviewProvider | null | undefined
): provider is HostedReviewCreationProvider {
return (
provider === 'github' ||
provider === 'gitlab' ||
provider === 'azure-devops' ||
provider === 'gitea'
)
}
export function resolveHostedReviewCreationProvider(
provider: HostedReviewProvider | null | undefined
): HostedReviewCreationProvider {
return supportsHostedReviewCreation(provider) ? provider : 'github'
}

View File

@ -27,6 +27,19 @@ describe('buildPullRequestFieldsPrompt', () => {
expect(prompt).toContain('Additional user prompt:')
expect(prompt).toContain('Use conventional PR titles.')
})
it('tells the agent to preserve existing review templates', () => {
const prompt = buildPullRequestFieldsPrompt(
{
...context,
currentBody: '## Summary\n\n## Testing\n\n- [ ] Required checks'
},
''
)
expect(prompt).toContain('preserve its headings, required sections, and checklists')
expect(prompt).toContain('Leave genuinely unknown template items as TODO or unchecked')
})
})
describe('parseGeneratedPullRequestFields', () => {

View File

@ -41,6 +41,8 @@ export function buildPullRequestFieldsPrompt(
'- Keep the base branch as the current base unless the diff clearly targets a different branch.',
'- Title: concise, specific, no trailing period.',
'- Body: useful Markdown summary for reviewers. Include testing notes only when evidence exists.',
'- If Current description contains a pull request or merge request template, preserve its headings, required sections, and checklists while filling relevant sections from the branch changes.',
'- Leave genuinely unknown template items as TODO or unchecked instead of deleting them.',
'- draft: true only when the changes clearly look unfinished, WIP, or unsafe to review.',
'- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.',
'',