diff --git a/src/main/azure-devops/pull-request-creation.test.ts b/src/main/azure-devops/pull-request-creation.test.ts new file mode 100644 index 000000000..dfcb4c82b --- /dev/null +++ b/src/main/azure-devops/pull-request-creation.test.ts @@ -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).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' + }) + }) +}) diff --git a/src/main/azure-devops/pull-request-creation.ts b/src/main/azure-devops/pull-request-creation.ts new file mode 100644 index 000000000..ddd7a2c5a --- /dev/null +++ b/src/main/azure-devops/pull-request-creation.ts @@ -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 { + 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 { + 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( + 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 + } +} diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index d97771e09..e54ea4def 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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' }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 8582c1d99..5ac3d5eac 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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 { diff --git a/src/main/gitea/pull-request-creation.test.ts b/src/main/gitea/pull-request-creation.test.ts new file mode 100644 index 000000000..9680726c3 --- /dev/null +++ b/src/main/gitea/pull-request-creation.test.ts @@ -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).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' + }) + }) +}) diff --git a/src/main/gitea/pull-request-creation.ts b/src/main/gitea/pull-request-creation.ts new file mode 100644 index 000000000..b113b1783 --- /dev/null +++ b/src/main/gitea/pull-request-creation.ts @@ -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 { + 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 { + 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( + 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 + } +} diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 65217eedc..0d0a75d40 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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> 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> 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 } ) diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index f8f6a630d..4187f1d94 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -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 = { diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 52e65e6d1..476632a9d 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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 { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) @@ -612,11 +621,18 @@ export class RuntimeGitCommands { } let context: Awaited> 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 } ) diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index e668670b3..ca80c9697 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 227f6394c..b5937d8fc 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -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 } ) }) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index eef0ed2f6..06cc32054 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -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) { diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index fc023efc0..c08bebf1c 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -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, diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 41fa09379..de9eb9be2 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -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 diff --git a/src/main/source-control/hosted-review-api-request.ts b/src/main/source-control/hosted-review-api-request.ts new file mode 100644 index 000000000..90e4d197a --- /dev/null +++ b/src/main/source-control/hosted-review-api-request.ts @@ -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 { + try { + return await response.text() + } catch { + return '' + } +} + +export async function requestHostedReviewJson( + url: URL, + init: Omit, + timeoutMs: number +): Promise { + 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) + } +} diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 117f6ffcb..77a3d60b8 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -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() + }) }) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 2dbde4b03..7b38d7c07 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -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 { + 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 { - if (input.provider !== 'github' && input.provider !== 'gitlab') { + if (!supportsHostedReviewCreation(input.provider)) { return { ok: false, code: 'unsupported_provider', diff --git a/src/main/source-control/pull-request-template.ts b/src/main/source-control/pull-request-template.ts new file mode 100644 index 000000000..47e0b7e39 --- /dev/null +++ b/src/main/source-control/pull-request-template.ts @@ -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 { + return readHostedReviewTemplate(repoPath, connectionId) +} + +export async function readHostedReviewTemplate( + repoPath: string, + connectionId?: string | null, + provider?: HostedReviewProvider | null +): Promise { + 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 { + 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) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3f0dbbbb0..1d9863b73 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index ba03435ee..a4311c662 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2610,6 +2610,8 @@ const api = { title: string body: string draft: boolean + provider?: unknown + useTemplate?: boolean connectionId?: string sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts index a1cbc64d8..a962481cf 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -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 }) }) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts index 3dfd1fb05..a19fe8f74 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 77a42e7c5..bb372bc7c 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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 => { 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, diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 32317c593..7aeaf80be 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -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 = {}) { 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 = {}) { } } -function renderCommitArea(props: ReturnType): string { +function renderCommitArea(props: Parameters[0]): string { return renderToStaticMarkup( @@ -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(//g) ?? []).some((button) => + button.includes('Commit') + ) + ).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', () => { diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx index a1e789440..1b6114ae7 100644 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -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(null) - const provider = eligibility?.provider === 'gitlab' ? 'gitlab' : 'github' + const provider = resolveHostedReviewCreationProvider(eligibility?.provider) const copy = reviewCopy(provider) const prCreationDefaults = React.useMemo(() => { if (!settings) { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 447f4580b..1290bd5e8 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -8,6 +8,7 @@ import { CloudUpload, Minus, Plus, + Loader2, RefreshCw, Settings2, Sparkle, @@ -53,7 +54,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { - resolvePrimaryAction, + resolveCommitAreaPrimaryAction, type PrimaryAction, type RemoteOpKind } from './source-control-primary-action' @@ -86,7 +87,10 @@ import { SourceControlDiscardDialog, type PendingDiscardConfirmation } from './source-control-discard-dialog' -import { refreshGitStatusForWorktree } from './git-status-refresh' +import { + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict +} from './git-status-refresh' import { describeForkPushTarget } from './fork-push-target-label' import { toast } from 'sonner' import { @@ -166,6 +170,8 @@ import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review' +import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers' +import { humanizeBranchSlug } from '../../../../shared/branch-name-from-work' import { STATUS_COLORS, STATUS_LABELS } from './status-display' import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec' import { @@ -190,6 +196,7 @@ import { } from './source-control-split-open' import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog' import { SourceControlTextGenerationDialog } from './SourceControlTextGenerationDialog' +import { CreateHostedReviewComposer } from './CreateHostedReviewComposer' import { hasConfiguredCommitMessageGenerationDefaults, hasConfiguredSourceControlTextGenerationDefaults @@ -200,7 +207,16 @@ import { localizedHostedReviewCopy, resolveSupportedHostedReviewCopyProvider } from '@/i18n/hosted-review-localized-copy' -import { CreateHostedReviewComposer } from './CreateHostedReviewComposer' +import { + createCreatePrIntentRunToken, + createPrIntentGitStatusMatchesToken, + createPrIntentRunTokenMatches, + getCreatePrIntentStagePaths, + resolveCreatePrIntentRemoteStep, + type CreatePrIntentRunToken +} from './source-control-create-pr-intent-flow' +import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' +import { resolveCreatePrHeaderAction } from './source-control-primary-create-pr-intent-action' import { createRunningPullRequestGenerationRecord, getPullRequestGenerationRecordKey, @@ -232,6 +248,12 @@ export type SourceControlActionError = { kind: RemoteOpKind | AbortActionErrorKind message: string } +type CreatePrIntentTone = 'muted' | 'destructive' +type CreatePrIntentNotice = { + message: string + tone: CreatePrIntentTone + action?: 'settings' +} export function resolveSourceControlBaseRef(input: { worktreeBaseRef?: string | null @@ -272,6 +294,7 @@ const PRIMARY_ICONS: Partial< push: ArrowUp, sync: ArrowDownUp, publish: CloudUpload, + create_pr_intent: GitPullRequestArrow, create_pr: GitPullRequestArrow } @@ -846,6 +869,7 @@ function SourceControlInner(): React.JSX.Element { // Why: commit drafts/errors are worktree-scoped during the mounted session, // so switching worktrees restores each draft instead of wiping it. const [commitDrafts, setCommitDrafts] = useState({}) + const commitDraftsRef = useRef(commitDrafts) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< Record @@ -879,9 +903,29 @@ function SourceControlInner(): React.JSX.Element { const [createPrInFlightByWorktree, setCreatePrInFlightByWorktree] = useState< Record >({}) - const [createPrErrors, setCreatePrErrors] = useState>({}) const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false - const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null + const createPrIntentInFlightRef = useRef>({}) + const createPrIntentRunTokenRef = useRef>({}) + const createPrIntentCurrentTargetRef = useRef({ + repoId: null as string | null, + worktreeId: null as string | null, + worktreePath: null as string | null, + branch: null as string | null + }) + const [createPrIntentInFlightByWorktree, setCreatePrIntentInFlightByWorktree] = useState< + Record + >({}) + const [createPrIntentNotices, setCreatePrIntentNotices] = useState< + Record + >({}) + const isCreatePrIntentInFlight = createPrIntentInFlightByWorktree[activeWorktreeId ?? ''] ?? false + const createPrIntentNotice = createPrIntentNotices[activeWorktreeId ?? ''] ?? null + const setCreatePrIntentNoticeForWorktree = useCallback( + (worktreeId: string, notice: CreatePrIntentNotice | null): void => { + setCreatePrIntentNotices((prev) => ({ ...prev, [worktreeId]: notice })) + }, + [] + ) const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) const allocatePullRequestGenerationRequestId = useAppStore( (s) => s.allocatePullRequestGenerationRequestId @@ -902,6 +946,21 @@ function SourceControlInner(): React.JSX.Element { : EMPTY_GIT_HISTORY_STATE const isGitHistoryExpanded = !collapsedSections.has('history') + useEffect(() => { + commitDraftsRef.current = commitDrafts + }, [commitDrafts]) + + const updateCommitDrafts = useCallback( + (updater: (drafts: CommitDraftsByWorktree) => CommitDraftsByWorktree): void => { + const next = updater(commitDraftsRef.current) + // Why: Create PR intent reads this ref after awaits to avoid overwriting + // user edits made before React's passive state sync effect runs. + commitDraftsRef.current = next + setCommitDrafts(next) + }, + [] + ) + const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null const activeConnectionId = activeWorktreeId @@ -914,6 +973,14 @@ function SourceControlInner(): React.JSX.Element { const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' + useEffect(() => { + createPrIntentCurrentTargetRef.current = { + repoId: activeRepo?.id ?? null, + worktreeId: activeWorktreeId ?? null, + worktreePath, + branch: branchName + } + }, [activeRepo?.id, activeWorktreeId, branchName, worktreePath]) const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath, @@ -975,6 +1042,36 @@ function SourceControlInner(): React.JSX.Element { } }, [refreshActiveGitStatus]) + const refreshActiveGitStatusAfterMutationStrict = useCallback(async () => { + if (!activeWorktreeId || !worktreePath || isFolder) { + return null + } + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + return await refreshGitStatusForWorktreeStrict({ + // Why: the PR intent sequence chains decisions after refresh, so this + // variant must fail loudly instead of letting stale snapshots drive pushes. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId, + pushTarget: activeWorktree?.pushTarget, + deps: { + setGitStatus, + updateWorktreeGitIdentity, + setUpstreamStatus + } + }) + }, [ + activeRepoSettings, + activeWorktree?.pushTarget, + activeWorktreeId, + isFolder, + setGitStatus, + setUpstreamStatus, + updateWorktreeGitIdentity, + worktreePath + ]) + // Why: when status is truncated at the entry limit, offer (once per worktree) // to .gitignore the folder most likely flooding it — the usual cause is a // build/dependency dir that should have been ignored. Accepting writes the @@ -1115,8 +1212,9 @@ function SourceControlInner(): React.JSX.Element { branchName === hostedReviewCreationState.branch ? hostedReviewCreationState.data : null - const hostedReviewCreateProvider = - hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github' + const hostedReviewCreateProvider = resolveHostedReviewCreationProvider( + hostedReviewCreation?.provider + ) const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider) const hostedReviewCacheKey = activeRepo && branchName @@ -1394,13 +1492,15 @@ function SourceControlInner(): React.JSX.Element { } return changed ? next : prev } - setCommitDrafts((prev) => pruneRecord(prev)) + updateCommitDrafts((prev) => pruneRecord(prev)) setCommitErrors((prev) => pruneRecord(prev)) setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateErrors((prev) => pruneRecord(prev)) + setCreatePrIntentInFlightByWorktree((prev) => pruneRecord(prev)) + setCreatePrIntentNotices((prev) => pruneRecord(prev)) setGitHistoryByWorktree((prev) => pruneRecord(prev)) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { @@ -1413,12 +1513,18 @@ function SourceControlInner(): React.JSX.Element { delete generateInFlightRef.current[key] } } + for (const key of Object.keys(createPrIntentInFlightRef.current)) { + if (!worktreeMap.has(key)) { + delete createPrIntentInFlightRef.current[key] + delete createPrIntentRunTokenRef.current[key] + } + } for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) { if (!worktreeMap.has(key)) { delete gitHistoryRequestByWorktreeRef.current[key] } } - }, [worktreeMap]) + }, [updateCommitDrafts, worktreeMap]) useEffect(() => { // Why: users often finish merge/rebase conflicts in a terminal. Once git @@ -1465,104 +1571,115 @@ function SourceControlInner(): React.JSX.Element { // Why: returns true on success so compound actions ("Commit & Push" etc.) // can skip the follow-up remote operation when the commit itself failed. - const handleCommit = useCallback(async (): Promise => { - if (!activeWorktreeId || !worktreePath) { - return false - } - const message = commitMessage.trim() - if (!message || grouped.staged.length === 0 || unresolvedConflicts.length > 0) { - return false - } - - if (commitInFlightRef.current[activeWorktreeId]) { - return false - } - commitInFlightRef.current[activeWorktreeId] = true - - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) - setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) - try { - const commitResult = await commitRuntimeGit( - { - // Why: route the commit by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - message - ) - if (!commitResult.success) { - setCommitErrors((prev) => ({ - ...prev, - [activeWorktreeId]: commitResult.error ?? 'Commit failed' - })) + const handleCommit = useCallback( + async ( + messageOverride?: string, + options?: { skipStagedSnapshotCheck?: boolean } + ): Promise => { + if (!activeWorktreeId || !worktreePath) { + return false + } + const message = (messageOverride ?? commitMessage).trim() + if ( + !message || + (!options?.skipStagedSnapshotCheck && grouped.staged.length === 0) || + unresolvedConflicts.length > 0 + ) { return false } - // Why: the textarea stays enabled during the in-flight commit (only the - // button is disabled), so the user can keep typing after clicking Commit. - // Unconditionally clearing the draft here would silently discard those - // in-progress edits — the commit used the OLD `message` captured in this - // closure, so the dropped text would never have been committed either. - // Only clear when the current draft still matches what we committed. - setCommitDrafts((prev) => { - const current = prev[activeWorktreeId] - if (current !== undefined && current.trim() !== message) { - // User typed more after submit — preserve their in-progress edits. - return prev - } - return writeCommitDraftForWorktree(prev, activeWorktreeId, '') - }) - setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) - void refreshActiveGitStatusAfterMutation() - // Why: flip branchSummary to 'loading' synchronously so the empty-state - // guard - // (!hasUncommittedEntries && branchSummary.status === 'ready' && - // branchEntries.length === 0) - // doesn't briefly read true between setGitStatus clearing the - // uncommitted list and the next branchCompare poll landing the new - // commit. Without this flip "No changes on this branch" flashes for - // the full poll-interval window. - // - // Then fire-and-forget refreshBranchCompare so the "Committed on - // Branch" section repopulates as soon as the IPC returns instead of - // waiting up to 5 seconds for the next poll. Unawaited on purpose: - // compound flows (runCompoundCommitAction) need handleCommit to - // resolve immediately so the push step starts without delay. Errors - // here are best-effort — the polling tick will retry. - if (effectiveBaseRef) { - beginGitBranchCompareRequest( - activeWorktreeId, - `${activeWorktreeId}:${effectiveBaseRef}:${Date.now()}:post-commit`, - effectiveBaseRef - ) + if (commitInFlightRef.current[activeWorktreeId]) { + return false } - void refreshBranchCompareRef.current() - void refreshGitHistoryRef.current() - return true - } catch (error) { - setCommitErrors((prev) => ({ - ...prev, - [activeWorktreeId]: error instanceof Error ? error.message : 'Commit failed' - })) - return false - } finally { - setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) - commitInFlightRef.current[activeWorktreeId] = false - } - }, [ - activeRepoSettings, - activeWorktreeId, - beginGitBranchCompareRequest, - commitMessage, - effectiveBaseRef, - grouped.staged.length, - refreshActiveGitStatusAfterMutation, - unresolvedConflicts.length, - worktreePath - ]) + commitInFlightRef.current[activeWorktreeId] = true + + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + try { + const commitResult = await commitRuntimeGit( + { + // Why: route the commit by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + message + ) + if (!commitResult.success) { + setCommitErrors((prev) => ({ + ...prev, + [activeWorktreeId]: commitResult.error ?? 'Commit failed' + })) + return false + } + + // Why: the textarea stays enabled during the in-flight commit (only the + // button is disabled), so the user can keep typing after clicking Commit. + // Unconditionally clearing the draft here would silently discard those + // in-progress edits — the commit used the OLD `message` captured in this + // closure, so the dropped text would never have been committed either. + // Only clear when the current draft still matches what we committed. + updateCommitDrafts((prev) => { + const current = prev[activeWorktreeId] + if (current !== undefined && current.trim() !== message) { + // User typed more after submit — preserve their in-progress edits. + return prev + } + return writeCommitDraftForWorktree(prev, activeWorktreeId, '') + }) + setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + void refreshActiveGitStatusAfterMutation() + // Why: flip branchSummary to 'loading' synchronously so the empty-state + // guard + // (!hasUncommittedEntries && branchSummary.status === 'ready' && + // branchEntries.length === 0) + // doesn't briefly read true between setGitStatus clearing the + // uncommitted list and the next branchCompare poll landing the new + // commit. Without this flip "No changes on this branch" flashes for + // the full poll-interval window. + // + // Then fire-and-forget refreshBranchCompare so the "Committed on + // Branch" section repopulates as soon as the IPC returns instead of + // waiting up to 5 seconds for the next poll. Unawaited on purpose: + // compound flows (runCompoundCommitAction) need handleCommit to + // resolve immediately so the push step starts without delay. Errors + // here are best-effort — the polling tick will retry. + if (effectiveBaseRef) { + beginGitBranchCompareRequest( + activeWorktreeId, + `${activeWorktreeId}:${effectiveBaseRef}:${Date.now()}:post-commit`, + effectiveBaseRef + ) + } + void refreshBranchCompareRef.current() + void refreshGitHistoryRef.current() + return true + } catch (error) { + setCommitErrors((prev) => ({ + ...prev, + [activeWorktreeId]: error instanceof Error ? error.message : 'Commit failed' + })) + return false + } finally { + setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + commitInFlightRef.current[activeWorktreeId] = false + } + }, + [ + activeRepoSettings, + activeWorktreeId, + beginGitBranchCompareRequest, + commitMessage, + effectiveBaseRef, + grouped.staged.length, + refreshActiveGitStatusAfterMutation, + updateCommitDrafts, + unresolvedConflicts.length, + worktreePath + ] + ) const handleGenerate = useCallback( async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise => { @@ -1625,7 +1742,7 @@ function SourceControlInner(): React.JSX.Element { // Why: race protection — the user may have started typing into the // textarea while the agent was running. In that case we silently drop // the generated message rather than overwrite their in-progress edits. - setCommitDrafts((prev) => { + updateCommitDrafts((prev) => { const current = prev[activeWorktreeId] if (current && current.length > 0) { return prev @@ -1645,7 +1762,13 @@ function SourceControlInner(): React.JSX.Element { generateInFlightRef.current[activeWorktreeId] = false } }, - [activeRepoSettings, activeWorktreeId, resolvedCommitMessageAi, worktreePath] + [ + activeRepoSettings, + activeWorktreeId, + resolvedCommitMessageAi, + updateCommitDrafts, + worktreePath + ] ) const handleGenerateCommitMessageClick = useCallback((): void => { @@ -1659,6 +1782,74 @@ function SourceControlInner(): React.JSX.Element { openCommitGenerationDialog() }, [activeRepo, handleGenerate, openCommitGenerationDialog, resolvedCommitMessageAi, settings]) + const generateCommitMessageForCreatePrIntent = useCallback(async (): Promise<{ + ok: boolean + message?: string + reason?: 'settings' | 'failed' | 'canceled' + }> => { + if (!activeWorktreeId || !worktreePath) { + return { ok: false, reason: 'failed' } + } + if ( + !hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) || + resolvedCommitMessageAi?.ok !== true + ) { + return { ok: false, reason: 'settings' } + } + if (isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)) { + const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? '' + if (!command) { + return { ok: false, reason: 'settings' } + } + } + if (generateInFlightRef.current[activeWorktreeId]) { + return { ok: false, reason: 'failed' } + } + + generateInFlightRef.current[activeWorktreeId] = true + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + try { + const result = await generateRuntimeCommitMessage( + { + // Why: route generation by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + { sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params } + ) + if (!result.success) { + if (!result.canceled) { + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: result.error })) + } + return { ok: false, reason: result.canceled ? 'canceled' : 'failed' } + } + useAppStore.getState().recordFeatureInteraction('ai-commit-generation') + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + return { ok: true, message: result.message } + } catch (error) { + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: + error instanceof Error ? error.message : 'Failed to generate commit message' + })) + return { ok: false, reason: 'failed' } + } finally { + setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + generateInFlightRef.current[activeWorktreeId] = false + } + }, [ + activeRepo, + activeRepoSettings, + activeWorktreeId, + resolvedCommitMessageAi, + settings, + worktreePath + ]) + const handleCancelGenerate = useCallback((): void => { if (!activeWorktreeId || !worktreePath) { return @@ -1694,9 +1885,9 @@ function SourceControlInner(): React.JSX.Element { | 'fetch' | 'publish' | 'rebase' - ): Promise => { + ): Promise => { if (!activeWorktreeId || !worktreePath) { - return + return false } const connectionId = getConnectionId(activeWorktreeId) ?? undefined setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) @@ -1710,7 +1901,7 @@ function SourceControlInner(): React.JSX.Element { activeWorktree?.pushTarget, { runtimeTargetSettings: activeRepoSettings } ) - return + return true } if (kind === 'push') { const forceWithLease = shouldForcePushWithLeaseForUpstream(remoteStatus) @@ -1724,7 +1915,7 @@ function SourceControlInner(): React.JSX.Element { ? { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } : { runtimeTargetSettings: activeRepoSettings } ) - return + return true } if (kind === 'force_push') { await pushBranch( @@ -1735,7 +1926,7 @@ function SourceControlInner(): React.JSX.Element { activeWorktree?.pushTarget, { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } ) - return + return true } if (kind === 'pull') { await pullBranch( @@ -1747,7 +1938,7 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: activeRepoSettings } ) - return + return true } if (kind === 'fast_forward') { await fastForwardBranch( @@ -1757,7 +1948,7 @@ function SourceControlInner(): React.JSX.Element { activeWorktree?.pushTarget, { runtimeTargetSettings: activeRepoSettings } ) - return + return true } if (kind === 'fetch') { await fetchBranch( @@ -1769,11 +1960,11 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: activeRepoSettings } ) - return + return true } if (kind === 'rebase') { if (!effectiveBaseRef) { - return + return false } await rebaseFromBase( activeWorktreeId, @@ -1783,12 +1974,13 @@ function SourceControlInner(): React.JSX.Element { activeWorktree?.pushTarget, { runtimeTargetSettings: activeRepoSettings } ) - return + return true } await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget, { runtimeTargetSettings: activeRepoSettings }) setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + return true } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep // one consistent toast path and avoid duplicate notifications in the UI. @@ -1801,6 +1993,7 @@ function SourceControlInner(): React.JSX.Element { message: resolveRemoteActionError(kind, error) } })) + return false } finally { refreshSourceControlAfterRemoteAction({ refreshGitStatus: refreshActiveGitStatusAfterMutation, @@ -1956,13 +2149,34 @@ function SourceControlInner(): 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 : linkedGitHubPR, + 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') { await fetchHostedReviewForBranch(activeRepo.path, branchName, { force: true, repoId: activeRepo.id, - linkedGitHubPR, - fallbackGitHubPR: fallbackGitHubPRNumber, - linkedGitLabMR: result.number + ...linkedReviewNumbers + }) + return + } + if (result.provider !== 'github') { + await fetchHostedReviewForBranch(activeRepo.path, branchName, { + force: true, + repoId: activeRepo.id, + ...linkedReviewNumbers }) return } @@ -1970,8 +2184,7 @@ function SourceControlInner(): React.JSX.Element { fetchHostedReviewForBranch(activeRepo.path, branchName, { force: true, repoId: activeRepo.id, - linkedGitHubPR: result.number, - linkedGitLabMR + ...linkedReviewNumbers }), fetchPRForBranch(activeRepo.path, branchName, { force: true, @@ -2006,6 +2219,9 @@ function SourceControlInner(): React.JSX.Element { fallbackGitHubPRNumber, fetchHostedReviewForBranch, fetchPRForBranch, + linkedAzureDevOpsPR, + linkedBitbucketPR, + linkedGiteaPR, linkedGitHubPR, linkedGitLabMR, setRightSidebarOpen, @@ -2072,7 +2288,9 @@ function SourceControlInner(): React.JSX.Element { base: stripBaseRef(seed.base.trim()), title: seed.title, body: seed.body, - draft: seed.draft + draft: seed.draft, + provider: hostedReviewCreateProvider, + useTemplate: resolvedPrCreationDefaults.useTemplate }, overrides ) @@ -2123,7 +2341,9 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, allocatePullRequestGenerationRequestId, branchName, + hostedReviewCreateProvider, refreshGitStatusAfterPullRequestGeneration, + resolvedPrCreationDefaults.useTemplate, setPullRequestGenerationRecord, updatePullRequestGenerationRecord, worktreePath @@ -2268,13 +2488,11 @@ function SourceControlInner(): React.JSX.Element { setHostedReviewCreationState(null) return } - // Why: skip refetches while the user's PR flow is mid-flight. AI generation - // rebases the branch (changing ahead/behind), and submission runs network - // calls that briefly perturb the same counts. Either flip can switch - // canCreate to false and tear down the composer underneath the user. The - // post-completion eligibility refresh in handlePullRequestCreated / - // onBranchChangedByGeneration restores the truth once the work settles. - if (prGenerating || isCreatingPr) { + // Why: skip refetches while the user's PR flow is mid-flight. AI generation, + // Create PR intent, and submission can all perturb ahead/behind or dirty + // state temporarily. Recomputing eligibility mid-flow can tear down the + // composer or rotate dropdown hints before the final refresh restores truth. + if (prGenerating || isCreatingPr || isCreatePrIntentInFlight) { return } let stale = false @@ -2322,6 +2540,7 @@ function SourceControlInner(): React.JSX.Element { hasUncommittedEntries, isBranchVisible, isCreatingPr, + isCreatePrIntentInFlight, isFolder, linkedGitHubPR, fallbackGitHubPRNumber, @@ -2353,32 +2572,32 @@ function SourceControlInner(): React.JSX.Element { const title = prTitle.trim() if (!title) { - setCreatePrErrors((prev) => ({ - ...prev, - [activeWorktreeId]: translate( + setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + tone: 'destructive', + message: translate( 'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5', 'Enter a {{value0}} title.', { value0: hostedReviewCreateCopy.reviewLabel } ) - })) + }) return } if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) { - setCreatePrErrors((prev) => ({ - ...prev, - [activeWorktreeId]: translate( + setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + tone: 'destructive', + message: translate( 'auto.components.right.sidebar.SourceControl.ae743199cd', 'Choose a different base branch before creating a {{value0}}.', { value0: hostedReviewCreateCopy.reviewLabel } ) - })) + }) return } createPrInFlightRef.current[activeWorktreeId] = true setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) - setCreatePrErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) try { const result = await createHostedReview(activeRepo.path, { repoId: activeRepo.id, @@ -2393,6 +2612,7 @@ function SourceControlInner(): React.JSX.Element { }) if (result.ok) { + setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) await handlePullRequestCreated({ provider: hostedReviewCreateProvider, number: result.number, @@ -2430,6 +2650,7 @@ function SourceControlInner(): React.JSX.Element { } ) if (number) { + setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) await handlePullRequestCreated({ provider: hostedReviewCreateProvider, number, @@ -2439,11 +2660,14 @@ function SourceControlInner(): React.JSX.Element { } } - setCreatePrErrors((prev) => ({ ...prev, [activeWorktreeId]: result.error })) + setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + tone: 'destructive', + message: result.error + }) } catch (error) { - setCreatePrErrors((prev) => ({ - ...prev, - [activeWorktreeId]: + setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + tone: 'destructive', + message: error instanceof Error ? error.message : translate( @@ -2451,7 +2675,7 @@ function SourceControlInner(): React.JSX.Element { 'Failed to create {{value0}}', { value0: hostedReviewCreateCopy.reviewLabel } ) - })) + }) } finally { createPrInFlightRef.current[activeWorktreeId] = false setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) @@ -2474,6 +2698,603 @@ function SourceControlInner(): React.JSX.Element { prTitle, resolvedPrCreationDefaults.openAfterCreate, resolvedPrCreationDefaults.useTemplate, + setCreatePrIntentNoticeForWorktree, + worktreePath + ]) + + const createHostedReviewForCreatePrIntent = useCallback( + async ( + token: CreatePrIntentRunToken, + eligibility: HostedReviewCreationEligibility + ): Promise => { + if (!activeRepo || !branchName || !eligibility.canCreate) { + return false + } + + const base = stripBaseRef( + eligibility.defaultBaseRef ?? effectiveBaseRef ?? prBase ?? '' + ).trim() + if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.ae743199cd', + 'Choose a different base branch before creating a {{value0}}.', + { value0: hostedReviewCreateCopy.reviewLabel } + ) + }) + return false + } + + const fallbackTitle = + eligibility.title?.trim() || + humanizeBranchSlug(stripBaseRef(branchName).split('/').pop()?.replace(/_/g, '-') ?? '') || + stripBaseRef(branchName) + let fields = { + base, + title: fallbackTitle, + body: eligibility.body ?? prBody, + draft: resolvedPrCreationDefaults.draft + } + + if ( + hasConfiguredSourceControlTextGenerationDefaults({ + actionId: 'pullRequest', + settings, + repo: activeRepo + }) + ) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentGeneratingDetails', + 'Generating review details…' + ) + }) + try { + const generated = await generateRuntimePullRequestFields( + { + // Why: direct Create PR intent submission can run after focus + // changes; keep generation pinned to the original worktree. + settings: activeRepoSettings, + worktreeId: token.worktreeId, + worktreePath: token.worktreePath, + connectionId: getConnectionId(token.worktreeId) ?? undefined + }, + { + ...fields, + provider: eligibility.provider, + useTemplate: resolvedPrCreationDefaults.useTemplate + } + ) + if (generated.success) { + fields = { + // Why: Create PR intent auto-submits; generated details should + // not retarget the review without user confirmation. + base: fields.base, + title: generated.fields.title.trim() || fields.title, + body: generated.fields.body, + draft: generated.fields.draft + } + } + } catch (error) { + console.warn('[SourceControl] Create PR intent detail generation failed', error) + } + } + + if (!createPrIntentRunTokenMatches(token, createPrIntentCurrentTargetRef.current)) { + return false + } + + const title = fields.title.trim() + if (!title) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5', + 'Enter a {{value0}} title.', + { value0: hostedReviewCreateCopy.reviewLabel } + ) + }) + return false + } + + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCreatingReview', + 'Creating review…' + ) + }) + createPrInFlightRef.current[token.worktreeId] = true + setCreatePrInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: true })) + try { + const result = await createHostedReview(activeRepo.path, { + repoId: activeRepo.id, + provider: eligibility.provider, + base: fields.base, + head: normalizeHostedReviewHeadRef(branchName), + title, + body: fields.body, + draft: fields.draft, + worktreePath: token.worktreePath, + useTemplate: resolvedPrCreationDefaults.useTemplate + }) + + if (result.ok) { + await handlePullRequestCreated({ + provider: eligibility.provider, + number: result.number, + url: result.url + }) + if (resolvedPrCreationDefaults.openAfterCreate) { + window.api.shell.openUrl(result.url) + } + setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + return true + } + + if (result.existingReview?.number && result.existingReview.url) { + await handlePullRequestCreated({ + provider: eligibility.provider, + number: result.existingReview.number, + url: result.existingReview.url + }) + setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + return true + } + + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: result.error + }) + return false + } catch (error) { + const message = + error instanceof Error + ? error.message + : translate( + 'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4', + 'Failed to create {{value0}}', + { value0: hostedReviewCreateCopy.reviewLabel } + ) + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message + }) + return false + } finally { + createPrInFlightRef.current[token.worktreeId] = false + setCreatePrInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: false })) + } + }, + [ + activeRepo, + activeRepoSettings, + branchName, + createHostedReview, + effectiveBaseRef, + handlePullRequestCreated, + hostedReviewCreateCopy.reviewLabel, + prBase, + prBody, + resolvedPrCreationDefaults.draft, + resolvedPrCreationDefaults.openAfterCreate, + resolvedPrCreationDefaults.useTemplate, + setCreatePrIntentNoticeForWorktree, + settings + ] + ) + + const refreshBranchCompareForCreatePrIntent = useCallback( + async (token: CreatePrIntentRunToken): Promise => { + if (!effectiveBaseRef) { + return undefined + } + const requestKey = `${token.worktreeId}:${effectiveBaseRef}:${Date.now()}:create-pr-intent` + beginGitBranchCompareRequest(token.worktreeId, requestKey, effectiveBaseRef) + const result = await getRuntimeGitBranchCompare( + { + // Why: the intent flow may continue after a worktree switch; use the + // token's original host target, not whatever branch is focused later. + settings: activeRepoSettings, + worktreeId: token.worktreeId, + worktreePath: token.worktreePath, + connectionId: getConnectionId(token.worktreeId) ?? undefined + }, + effectiveBaseRef + ) + setGitBranchCompareResult(token.worktreeId, requestKey, result) + return result.summary.status === 'ready' ? (result.summary.commitsAhead ?? 0) : undefined + }, + [activeRepoSettings, beginGitBranchCompareRequest, effectiveBaseRef, setGitBranchCompareResult] + ) + + const readHostedReviewCreationEligibilityForIntent = useCallback( + async ({ + hasUncommittedChanges, + upstreamStatus + }: { + hasUncommittedChanges: boolean + upstreamStatus?: NonNullable + }): Promise => { + if (!activeRepo || !activeWorktreeId || !branchName) { + return null + } + const result = await getHostedReviewCreationEligibility({ + repoPath: activeRepo.path, + repoId: activeRepo.id, + ...(worktreePath ? { worktreePath } : {}), + branch: branchName, + base: effectiveBaseRef ?? null, + hasUncommittedChanges, + hasUpstream: upstreamStatus?.hasUpstream, + ahead: upstreamStatus?.ahead, + behind: upstreamStatus?.behind, + linkedGitHubPR, + fallbackGitHubPR: fallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR + }) + setHostedReviewCreationState({ + repoId: activeRepo.id, + worktreeId: activeWorktreeId, + branch: branchName, + data: result + }) + return result + }, + [ + activeRepo, + activeWorktreeId, + branchName, + effectiveBaseRef, + fallbackGitHubPRNumber, + getHostedReviewCreationEligibility, + linkedAzureDevOpsPR, + linkedBitbucketPR, + linkedGiteaPR, + linkedGitHubPR, + linkedGitLabMR, + worktreePath + ] + ) + + const runCreatePrIntent = useCallback(async (): Promise => { + if ( + !activeRepo || + !activeWorktreeId || + !worktreePath || + !branchName || + isExecutingBulk || + isCommitting || + isGenerating || + isRemoteOperationActive || + prGenerating || + isCreatingPr || + createPrIntentInFlightRef.current[activeWorktreeId] + ) { + return + } + + const token = createCreatePrIntentRunToken({ + repoId: activeRepo.id, + worktreeId: activeWorktreeId, + worktreePath, + branch: branchName + }) + const runIsCurrent = (): boolean => + createPrIntentRunTokenMatches(token, createPrIntentCurrentTargetRef.current) + let abortedByStaleTarget = false + const abortIfStale = (): boolean => { + if (runIsCurrent()) { + return false + } + abortedByStaleTarget = true + return true + } + createPrIntentRunTokenRef.current[token.worktreeId] = token + createPrIntentInFlightRef.current[token.worktreeId] = true + setCreatePrIntentInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: true })) + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.d37e68f61d', + 'Preparing branch for review…' + ) + }) + + try { + let latestStatusEntries = entries + let latestUpstreamStatus = remoteStatus + const refreshIntentSnapshot = async (): Promise => { + const refreshed = await refreshActiveGitStatusAfterMutationStrict() + if (!refreshed) { + return false + } + // Why: terminal checkouts are observed by this strict status snapshot + // before React updates createPrIntentCurrentTargetRef. Stop before the + // intent flow stages, commits, or pushes on a different branch. + if (!createPrIntentGitStatusMatchesToken(token, refreshed.status)) { + abortedByStaleTarget = true + return false + } + if (abortIfStale()) { + return false + } + latestStatusEntries = refreshed.status.entries + latestUpstreamStatus = refreshed.upstreamStatus + return true + } + const stageLatestIntentPaths = async (): Promise => { + const stagePaths = getCreatePrIntentStagePaths({ + unstaged: latestStatusEntries.filter((entry) => entry.area === 'unstaged'), + untracked: latestStatusEntries.filter((entry) => entry.area === 'untracked') + }) + if (stagePaths.length === 0) { + return true + } + setIsExecutingBulk(true) + try { + await bulkStageRuntimeGitPaths( + { + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: token.worktreeId, + worktreePath: token.worktreePath, + connectionId: getConnectionId(token.worktreeId) ?? undefined + }, + stagePaths + ) + } finally { + setIsExecutingBulk(false) + } + if (abortIfStale()) { + return false + } + return refreshIntentSnapshot() + } + + if (!(await refreshIntentSnapshot())) { + return + } + + if (!(await stageLatestIntentPaths())) { + return + } + + const stagedEntries = latestStatusEntries.filter((entry) => entry.area === 'staged') + if (stagedEntries.length > 0) { + let message = readCommitDraftForWorktree(commitDraftsRef.current, token.worktreeId).trim() + if (!message) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.8d8f5c6c94', + 'Generating commit message…' + ) + }) + const generated = await generateCommitMessageForCreatePrIntent() + if (abortIfStale()) { + return + } + if (!generated.ok || !generated.message) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: generated.reason === 'settings' ? 'muted' : 'destructive', + message: translate( + generated.reason === 'settings' + ? 'auto.components.right.sidebar.SourceControl.createPrIntentConfigureAi' + : 'auto.components.right.sidebar.SourceControl.createPrIntentGenerateFailed', + generated.reason === 'settings' + ? 'Add a commit message or configure Source Control AI settings.' + : 'Could not generate a commit message. Add one and retry.' + ), + action: generated.reason === 'settings' ? 'settings' : undefined + }) + return + } + const draftAfterGeneration = readCommitDraftForWorktree( + commitDraftsRef.current, + token.worktreeId + ).trim() + if (draftAfterGeneration) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.fda060d6ce', + 'Review the commit message, then retry Create PR.' + ) + }) + return + } + message = generated.message + updateCommitDrafts((prev) => writeCommitDraftForWorktree(prev, token.worktreeId, message)) + } + + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.b75cb1fd0c', + 'Committing changes…' + ) + }) + const committed = await handleCommit(message, { skipStagedSnapshotCheck: true }) + if (abortIfStale()) { + return + } + if (!committed) { + // Why: pre-commit/lint hooks may rewrite tracked files before + // failing. Re-stage those safe hook outputs so retrying Create PR + // does not strand changes outside the intended all-in commit. + if (await refreshIntentSnapshot()) { + await stageLatestIntentPaths() + } + if (abortIfStale()) { + return + } + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', + 'Could not commit changes. Fix the issue, then retry Create PR.' + ) + }) + return + } + if (!(await refreshIntentSnapshot())) { + return + } + } + + const branchAhead = await refreshBranchCompareForCreatePrIntent(token) + if (abortIfStale()) { + return + } + let eligibility = await readHostedReviewCreationEligibilityForIntent({ + hasUncommittedChanges: latestStatusEntries.length > 0, + upstreamStatus: latestUpstreamStatus + }) + if (abortIfStale() || !eligibility) { + return + } + if (eligibility.canCreate) { + await createHostedReviewForCreatePrIntent(token, eligibility) + if (abortIfStale()) { + return + } + return + } + if (eligibility.blockedReason === 'existing_review') { + setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + return + } + + const remoteStep = resolveCreatePrIntentRemoteStep({ + upstreamStatus: latestUpstreamStatus, + hostedReviewCreation: eligibility, + branchCommitsAhead: branchAhead, + hasCurrentBranch: Boolean(branchName) + }) + if (remoteStep === 'blocked' || remoteStep === 'none') { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + eligibility.blockedReason === 'needs_sync' + ? 'auto.components.right.sidebar.SourceControl.createPrIntentNeedsSync' + : 'auto.components.right.sidebar.SourceControl.createPrIntentBranchNotReady', + eligibility.blockedReason === 'needs_sync' + ? 'Sync this branch before creating a review.' + : 'Branch is not ready to create a review yet.' + ) + }) + return + } + + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + remoteStep === 'publish' + ? 'auto.components.right.sidebar.SourceControl.createPrIntentPublishing' + : remoteStep === 'force_push' + ? 'auto.components.right.sidebar.SourceControl.createPrIntentForcePushing' + : 'auto.components.right.sidebar.SourceControl.createPrIntentPushing', + remoteStep === 'publish' + ? 'Publishing branch…' + : remoteStep === 'force_push' + ? 'Force pushing with lease…' + : 'Pushing commits…' + ) + }) + const remoteOk = await runRemoteAction(remoteStep) + if (abortIfStale()) { + return + } + if (!remoteOk) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', + 'Could not update the remote branch. Retry Create PR.' + ) + }) + return + } + if (!(await refreshIntentSnapshot())) { + return + } + await refreshBranchCompareForCreatePrIntent(token) + if (abortIfStale()) { + return + } + eligibility = await readHostedReviewCreationEligibilityForIntent({ + hasUncommittedChanges: latestStatusEntries.length > 0, + upstreamStatus: latestUpstreamStatus + }) + if (abortIfStale()) { + return + } + if (eligibility?.canCreate) { + await createHostedReviewForCreatePrIntent(token, eligibility) + if (abortIfStale()) { + return + } + return + } + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.995c5e67ec', + 'Review setup needs attention.' + ) + }) + } catch (error) { + console.warn('[SourceControl] Create PR intent failed', error) + if (!abortIfStale()) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.d7492cafce', + 'Could not refresh Source Control. Retry Create PR.' + ) + }) + } + } finally { + if (createPrIntentRunTokenRef.current[token.worktreeId] === token) { + createPrIntentInFlightRef.current[token.worktreeId] = false + createPrIntentRunTokenRef.current[token.worktreeId] = null + if (abortedByStaleTarget) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + } + setCreatePrIntentInFlightByWorktree((prev) => ({ + ...prev, + [token.worktreeId]: false + })) + } + } + }, [ + activeRepo, + activeRepoSettings, + activeWorktreeId, + branchName, + createHostedReviewForCreatePrIntent, + entries, + generateCommitMessageForCreatePrIntent, + handleCommit, + isCommitting, + isCreatingPr, + isExecutingBulk, + isGenerating, + isRemoteOperationActive, + prGenerating, + readHostedReviewCreationEligibilityForIntent, + refreshActiveGitStatusAfterMutationStrict, + refreshBranchCompareForCreatePrIntent, + remoteStatus, + runRemoteAction, + setCreatePrIntentNoticeForWorktree, + updateCommitDrafts, worktreePath ]) @@ -2488,7 +3309,7 @@ function SourceControlInner(): React.JSX.Element { }, [grouped.staged, grouped.unstaged]) const primaryAction: PrimaryAction = useMemo(() => { - const action = resolvePrimaryAction({ + return resolveCommitAreaPrimaryAction({ stagedCount: grouped.staged.length, hasUnstagedChanges, hasStageableChanges, @@ -2504,19 +3325,9 @@ function SourceControlInner(): React.JSX.Element { hostedReviewCreation, branchCommitsAhead: branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined, - hasCurrentBranch: Boolean(branchName) + hasCurrentBranch: Boolean(branchName), + isPrIntentInFlight: isCreatePrIntentInFlight }) - return isCreatingPr && action.kind === 'create_pr' - ? { - ...action, - title: translate( - 'auto.components.right.sidebar.SourceControl.fe5bd1a610', - 'Creating {{value0}}...', - { value0: hostedReviewCreateCopy.reviewLabel } - ), - disabled: true - } - : action }, [ commitMessage, grouped.staged.length, @@ -2528,10 +3339,9 @@ function SourceControlInner(): React.JSX.Element { isRemoteOperationActive, inFlightRemoteOpKind, hostedReviewCreation, - hostedReviewCreateCopy.reviewLabel, isHostedReviewStateLoading, hostedReview?.state, - isCreatingPr, + isCreatePrIntentInFlight, branchSummary?.commitsAhead, branchSummary?.status, branchName, @@ -2539,6 +3349,68 @@ function SourceControlInner(): React.JSX.Element { unresolvedConflicts.length ]) + const createPrHeaderAction: PrimaryAction | null = useMemo(() => { + const action = resolveCreatePrHeaderAction({ + stagedCount: grouped.staged.length, + hasUnstagedChanges, + hasStageableChanges, + hasPartiallyStagedChanges, + hasMessage: commitMessage.trim().length > 0, + hasUnresolvedConflicts: unresolvedConflicts.length > 0, + isCommitting, + isRemoteOperationActive: isRemoteOperationActive || isAbortingOperation, + upstreamStatus: remoteStatus, + prState: hostedReview?.state ?? null, + isPRStateLoading: isHostedReviewStateLoading, + inFlightRemoteOpKind, + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined, + hasCurrentBranch: Boolean(branchName), + isPrIntentInFlight: isCreatePrIntentInFlight + }) + return isCreatingPr && action?.kind === 'create_pr' + ? { + ...action, + title: translate( + 'auto.components.right.sidebar.SourceControl.fe5bd1a610', + 'Creating {{value0}}...', + { value0: hostedReviewCreateCopy.reviewLabel } + ), + disabled: true + } + : action + }, [ + branchName, + branchSummary?.commitsAhead, + branchSummary?.status, + commitMessage, + grouped.staged.length, + hasPartiallyStagedChanges, + hasStageableChanges, + hasUnstagedChanges, + hostedReview?.state, + hostedReviewCreation, + hostedReviewCreateCopy.reviewLabel, + inFlightRemoteOpKind, + isAbortingOperation, + isCommitting, + isCreatePrIntentInFlight, + isCreatingPr, + isHostedReviewStateLoading, + isRemoteOperationActive, + remoteStatus, + unresolvedConflicts.length + ]) + const directCreatePrAction = + createPrHeaderAction?.kind === 'create_pr' ? createPrHeaderAction : null + const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction, + directCreatePrAction, + isCreatePrIntentInFlight, + primaryActionKind: primaryAction.kind + }) + const dropdownItems: DropdownEntry[] = useMemo( () => resolveDropdownItems({ @@ -2556,7 +3428,7 @@ function SourceControlInner(): React.JSX.Element { isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, hostedReviewCreation, - isPullRequestOperationActive: prGenerating || isCreatingPr, + isPullRequestOperationActive: prGenerating || isCreatingPr || isCreatePrIntentInFlight, branchCommitsAhead: branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined, hasCurrentBranch: Boolean(branchName), @@ -2575,6 +3447,7 @@ function SourceControlInner(): React.JSX.Element { inFlightRemoteOpKind, hostedReviewCreation, isCreatingPr, + isCreatePrIntentInFlight, isHostedReviewStateLoading, hostedReview?.state, prGenerating, @@ -2593,7 +3466,7 @@ function SourceControlInner(): React.JSX.Element { // pure remote actions go through runRemoteAction. const handleActionInvoke = useCallback( (kind: DropdownActionKind): void => { - if (prGenerating || isCreatingPr) { + if (prGenerating || isCreatingPr || isCreatePrIntentInFlight) { return } switch (kind) { @@ -2616,7 +3489,7 @@ function SourceControlInner(): React.JSX.Element { void handleCreatePullRequest() return case 'push_create_pr': - void runRemoteAction('push') + void runCreatePrIntent() return case 'push': case 'force_push': @@ -2635,7 +3508,9 @@ function SourceControlInner(): React.JSX.Element { handleAbortMerge, handleAbortRebase, isCreatingPr, + isCreatePrIntentInFlight, prGenerating, + runCreatePrIntent, runCompoundCommitAction, runRemoteAction ] @@ -2998,8 +3873,24 @@ function SourceControlInner(): React.JSX.Element { case 'publish': case 'create_pr': handleActionInvoke(primaryAction.kind) + return + case 'create_pr_intent': + void runCreatePrIntent() } - }, [handleActionInvoke, handleStageAllPrimary, primaryAction.kind]) + }, [handleActionInvoke, handleStageAllPrimary, primaryAction.kind, runCreatePrIntent]) + + const handleCreatePrHeaderClick = useCallback((): void => { + if (!createPrHeaderAction || createPrHeaderAction.disabled) { + return + } + if (createPrHeaderAction.kind === 'create_pr') { + void handleCreatePullRequest() + return + } + if (createPrHeaderAction.kind === 'create_pr_intent') { + void runCreatePrIntent() + } + }, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent]) const handleUnstageAll = useCallback(async () => { if (!worktreePath || isExecutingBulk) { @@ -3770,13 +4661,43 @@ function SourceControlInner(): React.JSX.Element { )} ))} - {hostedReview && ( + {(visibleCreatePrHeaderAction || hostedReview) && (
- - + {visibleCreatePrHeaderAction && ( + + + + + + + + {visibleCreatePrHeaderAction.title} + + + )} + {hostedReview && ( + <> + + + + )}
)} @@ -4071,7 +4992,7 @@ function SourceControlInner(): React.JSX.Element { ) : null} {shouldRenderCommitArea(scope, unresolvedConflicts.length, conflictOperation) && - (primaryAction.kind === 'create_pr' ? ( + (directCreatePrAction ? ( { + void handleCreatePullRequest() + }} onDropdownAction={handleActionInvoke} /> ) : ( @@ -4112,8 +5037,11 @@ function SourceControlInner(): React.JSX.Element { commitError={commitError} commitFailureRecoveryPrompt={commitFailureRecoveryPrompt} remoteActionError={remoteActionError?.message ?? null} + createPrIntentNotice={createPrIntentNotice} isCommitting={isCommitting} isFixingCommitFailureWithAI={isLaunchingCommitFailureAgent} + isCreatingPr={isCreatingPr || isCreatePrIntentInFlight} + isCreatePrIntentInFlight={isCreatePrIntentInFlight} groupId={activeGroupId ?? activeWorktreeId} showComposer={!(scope === 'all' && showGenericEmptyState)} aiEnabled={resolvedCommitMessageAi?.ok === true} @@ -4131,7 +5059,7 @@ function SourceControlInner(): React.JSX.Element { if (!activeWorktreeId) { return } - setCommitDrafts((prev) => + updateCommitDrafts((prev) => writeCommitDraftForWorktree(prev, activeWorktreeId, value) ) }} @@ -4829,9 +5757,11 @@ type CommitAreaProps = { commitError: string | null commitFailureRecoveryPrompt: string | null remoteActionError: string | null + createPrIntentNotice?: CreatePrIntentNotice | null isCommitting: boolean isFixingCommitFailureWithAI: boolean isCreatingPr?: boolean + isCreatePrIntentInFlight?: boolean showComposer?: boolean aiEnabled: boolean aiAgentConfigured: boolean @@ -4868,9 +5798,11 @@ export function CommitArea({ commitError, commitFailureRecoveryPrompt, remoteActionError, + createPrIntentNotice, isCommitting, isFixingCommitFailureWithAI, isCreatingPr = false, + isCreatePrIntentInFlight = false, showComposer = true, aiEnabled, aiAgentConfigured, @@ -4897,9 +5829,9 @@ export function CommitArea({ // the existing style) — the browser scrolls internally past 12 rows. const rows = Math.min(12, Math.max(2, commitMessage.split('\n').length)) // Why: only spin the primary when its label matches what's actually - // running. resolvePrimaryAction overrides the primary kind to mirror the - // in-flight op (e.g. user picks Sync from the dropdown → primary becomes - // "Sync"), so the equality check spins the button for any primary- + // running. The commit-area resolver overrides the primary kind to mirror + // the in-flight op (e.g. user picks Sync from the dropdown → primary + // becomes "Sync"), so the equality check spins the button for any primary- // eligible remote op the user triggered. Background ops the primary // doesn't show (Fetch) leave primaryAction.kind unchanged and the // mismatch keeps the spinner off — the disabled state alone is enough @@ -4909,7 +5841,7 @@ export function CommitArea({ primaryAction.kind === inFlightRemoteOpKind || (primaryAction.kind === 'push' && inFlightRemoteOpKind === 'force_push') const showSpinner = - primaryAction.kind === 'create_pr' + primaryAction.kind === 'create_pr' || primaryAction.kind === 'create_pr_intent' ? isCreatingPr : primaryAction.kind === 'commit' ? isCommitting @@ -4989,6 +5921,7 @@ export function CommitArea({ const describedBy = [ commitError ? 'commit-area-error' : null, remoteActionError ? 'commit-area-remote-error' : null, + createPrIntentNotice ? 'commit-area-create-pr-intent' : null, generateError ? 'commit-area-generate-error' : null ] .filter(Boolean) @@ -4996,7 +5929,10 @@ export function CommitArea({ // Why: only render Generate when it has a runnable path; otherwise the // composer should stay focused on the normal Commit action. - const showGenerate = showComposer && aiEnabled && (aiAgentConfigured || isGenerating) + // Why: Create PR intent owns message generation and surfaces status via the + // inline notice; a second composer spinner stacks on the primary spinner. + const showGenerate = + showComposer && aiEnabled && !isCreatePrIntentInFlight && (aiAgentConfigured || isGenerating) let generateDisabledReason: string | undefined if (isGenerating) { generateDisabledReason = 'Generating commit message…' @@ -5016,6 +5952,55 @@ export function CommitArea({ stagedCount === 0 || hasMessage || hasUnresolvedConflicts + const moreCommitAndRemoteActionsLabel = translate( + 'auto.components.right.sidebar.SourceControl.cc199ccc5f', + 'More commit and remote actions' + ) + const moreActionsLabel = translate( + 'auto.components.right.sidebar.SourceControl.4d6e1fd7f3', + 'More actions' + ) + const dropdownMenuContent = ( + + {dropdownItems.map((entry, index) => + entry.kind === 'separator' ? ( + + ) : ( + + +
+ { + if (entry.disabled) { + event.preventDefault() + return + } + onDropdownAction(entry.kind) + }} + > + + {entry.label} + {entry.hint ? ( + + {entry.hint} + + ) : null} + + +
+
+ + {entry.title} + +
+ ) + )} +
+ ) return (
@@ -5096,124 +6081,77 @@ export function CommitArea({ ))}
) : null} - {/* Why: primary + chevron sit together as a visual split button so the - edit → commit → push loop stays in a single vertical band. The - chevron exposes the full action surface (fetch, pull, sync, - publish, compound commits) without forcing morphing labels to - carry every possible intent. */} -
- {/* Why: match the hosted-review action buttons in Checks - (size="xs", px-3 text-[11px]) so the sidebar has a consistent - action-button shape across Source Control and Checks. The primary - and chevron share a single rounded rectangle — rounded-r-none on - the primary and rounded-l-none + border-l on the chevron make the - pair read as one split button instead of two detached buttons. */} - - - - - - - - {primaryAction.title} - - - + {/* Why: the current manual action + chevron sit together as a visual + split button so the edit → commit → push loop stays in a single + vertical band. The chevron exposes the full action surface without + forcing morphing labels to carry every possible intent. */} +
+
+ {/* Why: match the hosted-review action buttons in Checks + (size="xs", px-3 text-[11px]) so the sidebar has a consistent + action-button shape across Source Control and Checks. */} - - - - + + - - {translate( - 'auto.components.right.sidebar.SourceControl.cc199ccc5f', - 'More commit and remote actions' - )} + + {primaryAction.title} - - {dropdownItems.map((entry, index) => - entry.kind === 'separator' ? ( - - ) : ( - - -
- { - if (entry.disabled) { - event.preventDefault() - return - } - onDropdownAction(entry.kind) - }} - > - - {entry.label} - {entry.hint ? ( - - {entry.hint} - - ) : null} - - -
-
- - {entry.title} - -
- ) - )} -
- + + + + + + + + + + + {moreCommitAndRemoteActionsLabel} + + + {dropdownMenuContent} + +
{commitError && ( // Why: role="alert" + aria-live="polite" lets screen readers announce @@ -5353,6 +6291,33 @@ export function CommitArea({ {remoteActionError}

)} + {createPrIntentNotice && ( +
+ {createPrIntentNotice.message} + {createPrIntentNotice.action === 'settings' && onOpenSourceControlAiSettings ? ( + + ) : null} +
+ )} {generateError && (

| null + worktreeId: string + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + deps: Omit & { + 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 } +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts new file mode 100644 index 000000000..f04530b85 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts new file mode 100644 index 000000000..9b6e54bc2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -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) { + 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' +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts new file mode 100644 index 000000000..4861ad586 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts new file mode 100644 index 000000000..aedeec06c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts @@ -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 +} diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 6d9cbeb2a..21484eed1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -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({ diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 13f7114b4..6c58cd3dd 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -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['provider'] | undefined ): ReturnType & { - 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 = { diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts index 726729a23..ca3ee1897 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts @@ -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, string> = { @@ -73,5 +75,6 @@ export const PRIMARY_LABEL_BY_KIND: Record, pull: 'Pull', sync: 'Sync', publish: 'Publish Branch', + create_pr_intent: 'Create PR', create_pr: 'Create PR' } diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts new file mode 100644 index 000000000..62338e0ba --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts @@ -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 { + 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 + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index e32f9e8ae..f43d94ea0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -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 + }) + } + ) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts index db1fdb651..05eff4ae0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts @@ -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 + }) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts new file mode 100644 index 000000000..04901c430 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts @@ -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 +): 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) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-unpublished-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-unpublished-action.ts new file mode 100644 index 000000000..d7f182161 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-primary-unpublished-action.ts @@ -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 + } +} diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 4be16c0ba..48c6e3111 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -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, diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index 465e3b047..2ebc332f7 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -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 & Pick): 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', () => { diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx index aa9ed6632..c5965f172 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx @@ -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 { 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.' ) diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts index 1216f93ed..7727cc414 100644 --- a/src/renderer/src/components/settings/provider-account-scope.test.ts +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -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.' }) diff --git a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx index 28a892469..389964b4b 100644 --- a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx +++ b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx @@ -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() - 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 > Remote Orca Servers > Advanced to view server-owned budgets.' ) diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 778e1c369..0dd261919 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -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.' ) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 804f60223..14e59c106 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 } } diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 5bc6393ab..454268c24 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -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' } ]) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index effb71bda..269c5af47 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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' } ]) }) diff --git a/src/renderer/src/components/task-source-context-summary.test.ts b/src/renderer/src/components/task-source-context-summary.test.ts index 0fca41087..69cf53cbb 100644 --- a/src/renderer/src/components/task-source-context-summary.test.ts +++ b/src/renderer/src/components/task-source-context-summary.test.ts @@ -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({ diff --git a/src/renderer/src/i18n/hosted-review-localized-copy.ts b/src/renderer/src/i18n/hosted-review-localized-copy.ts index f86736fe6..be6a6b7ad 100644 --- a/src/renderer/src/i18n/hosted-review-localized-copy.ts +++ b/src/renderer/src/i18n/hosted-review-localized-copy.ts @@ -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'), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7ae5aa002..fe6c73e25 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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}}" } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0acfc6a1c..d9e3d29ca 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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}}" } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0d4c246ac..db024f307 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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}} を作成" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a43873824..e9e147898 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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}} 생성" } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 194233d18..98ea8a293 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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}}" } } } diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index 7f9691034..44c6142b9 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -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', diff --git a/src/renderer/src/lib/source-control-generation-plan.ts b/src/renderer/src/lib/source-control-generation-plan.ts index 2f76fd629..07b992b68 100644 --- a/src/renderer/src/lib/source-control-generation-plan.ts +++ b/src/renderer/src/lib/source-control-generation-plan.ts @@ -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, diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 41eda8efd..f7c9c02f0 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -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 & 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 { const target = getActiveRuntimeTarget(context.settings) diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index 4af2cc237..72c0dba0f 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -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' } ]) }) diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index 4548b1826..752a1beb6 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -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') + }) }) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 1f0424573..8df52bf5a 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -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)}` } diff --git a/src/shared/hosted-review-creation-providers.ts b/src/shared/hosted-review-creation-providers.ts new file mode 100644 index 000000000..b32cfebfb --- /dev/null +++ b/src/shared/hosted-review-creation-providers.ts @@ -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' +} diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts index fdf7173ca..45d6478c8 100644 --- a/src/shared/pull-request-generation.test.ts +++ b/src/shared/pull-request-generation.test.ts @@ -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', () => { diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts index 7edc1561c..1aab85c01 100644 --- a/src/shared/pull-request-generation.ts +++ b/src/shared/pull-request-generation.ts @@ -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.', '',