From a300e2c7b43467b17fdd07d02a0ab00a2f03dfdc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:36:27 -0700 Subject: [PATCH] Add Source Control Create PR flow (#5436) * Add Source Control Create PR intent flow Implements the Source Control Create PR flow described in docs/source-control-create-pr-flow.md. * Keep Commit visible beside Create PR * Fix Create PR partial staging action band * Integrate hosted review creation into Create PR intent flow - Automatically create the pull or merge request on GitHub/GitLab after successfully staging, committing, and pushing in the intent flow. - Introduce a unified `updateCommitDrafts` helper to keep React state and its ref synchronized, preventing draft-overwrite race conditions. - Split primary action tests into focused files to satisfy the ESLint `max-lines` rule. - Replace hardcoded "Local Mac" strings with dynamic host labels. * Support Azure DevOps and Gitea PR creation and limit large diffs Implement automated pull request creation for Azure DevOps and Gitea repositories. This includes REST API integration, credential checks via environment variables, template support, and error classification. Additionally, introduce limits on large diff payloads in git status extraction to prevent renderer-freezing performance bottlenecks when loading extremely large files. * Skip source control refetches when PR creation intent is in flight Avoid recomputing branch eligibility while isCreatePrIntentInFlight is true. This prevents tearing down the PR composer or rotating dropdown hints prematurely if ahead/behind or dirty states are temporarily perturbed temporarily perturbed mid-flow. * Expose manual prerequisite actions next to Create PR button Previously, the Create PR intent only supported "Stage All" as a sibling action. This expands prerequisite resolution to handle other intermediate steps such as committing, publishing, and pushing (including force pushing). This ensures the edit-commit-push-review loop remains streamlined directly within the CommitArea by displaying the specific required next action beside the primary Create PR button. * Move PR creation actions from CommitArea to sidebar header - Decouples PR creation and PR intent actions from the local commit area primary button, ensuring local/remote git actions remain primary. - Renders a dedicated PR creation button in the source control header beside the hosted review status. - Simplifies CommitArea by removing prerequisite split-button rendering and review composer logic. * Delete source control create PR flow design document Remove the design document for the source control create PR flow as the feature has been successfully implemented. * Display PR creation errors in inline notice Unify PR/review creation error reporting by replacing the duplicate createPrErrors state with the shared createPrIntentNotice. Validation and API errors are now shown directly within the visible inline alert notice to improve layout consistency and visibility. Also refactor the execution host platform label lookup to use simple if statements instead of a switch block. * Improve Create PR intent flow safety and provider awareness - Integrate the hosted review composer directly into the Source Control panel when a direct review creation action is available. - Abort the in-flight PR creation intent flow early if the current git branch changes to prevent staging or committing on the wrong target. - Keep in-flight action labels provider-aware (e.g., "Create MR" on GitLab) by passing hosted review inputs to the action resolver. - Omit large diff text payloads from git status responses when line counts exceed safe rendering limits to avoid UI performance degradation. - Ensure field generation does not retarget the base branch of a PR/MR without explicit user confirmation. * Preserve PR and MR templates in AI pull request generation - Preload templates (including GitLab merge requests) into the AI context before generation to prevent bypassing provider-side fallbacks. - Instruct the AI generator to fill out and preserve existing template headings, required sections, and checklists instead of deleting them. - Pass provider and template settings from the renderer to the backend RPC and runtime handlers. * Mock DropdownMenuShortcut in tab-title-tooltip test Add a mock for the DropdownMenuShortcut component in the dropdown menu mock to prevent test failures. --- .../pull-request-creation.test.ts | 155 ++ .../azure-devops/pull-request-creation.ts | 238 +++ src/main/git/status.test.ts | 32 +- src/main/git/status.ts | 2 + src/main/gitea/pull-request-creation.test.ts | 155 ++ src/main/gitea/pull-request-creation.ts | 208 +++ src/main/ipc/filesystem.ts | 22 +- src/main/runtime/orca-runtime-git.test.ts | 70 + src/main/runtime/orca-runtime-git.ts | 22 +- src/main/runtime/rpc/methods/git-params.ts | 6 +- src/main/runtime/rpc/methods/git.test.ts | 11 +- src/main/runtime/rpc/methods/git.ts | 4 +- .../source-control/forge-provider.test.ts | 90 +- src/main/source-control/forge-provider.ts | 12 +- .../hosted-review-api-request.ts | 48 + .../hosted-review-creation.test.ts | 174 ++ .../source-control/hosted-review-creation.ts | 81 +- .../source-control/pull-request-template.ts | 82 + src/preload/api-types.ts | 5 +- src/preload/index.ts | 2 + .../cmd-j/palette-host-badge.test.ts | 9 +- .../components/cmd-j/palette-host-badge.ts | 2 +- .../components/right-sidebar/ChecksPanel.tsx | 38 +- .../right-sidebar/CommitArea.test.tsx | 99 +- .../right-sidebar/CreatePullRequestDialog.tsx | 3 +- .../right-sidebar/SourceControl.tsx | 1543 ++++++++++++++--- .../right-sidebar/git-status-refresh.ts | 73 +- ...urce-control-create-pr-intent-flow.test.ts | 145 ++ .../source-control-create-pr-intent-flow.ts | 93 + ...rce-control-create-pr-intent-state.test.ts | 52 + .../source-control-create-pr-intent-state.ts | 98 ++ .../source-control-dropdown-items.test.ts | 48 + .../source-control-dropdown-items.ts | 17 +- .../source-control-primary-action-types.ts | 3 + ...ol-primary-action.create-pr-intent.test.ts | 281 +++ .../source-control-primary-action.test.ts | 83 +- .../source-control-primary-action.ts | 124 +- ...control-primary-create-pr-intent-action.ts | 96 + ...urce-control-primary-unpublished-action.ts | 88 + .../useCreatePullRequestDialogFields.ts | 6 +- .../RepositoryHostSetupsSection.test.tsx | 7 +- ...-source-control-integration-cards.test.tsx | 7 +- .../settings/provider-account-scope.test.ts | 8 +- .../provider-rate-limit-scope-panels.test.tsx | 7 +- .../task-tracker-integration-cards.test.tsx | 7 +- .../src/components/sidebar/WorktreeList.tsx | 4 +- .../sidebar/sidebar-host-options.test.ts | 15 +- .../sidebar/worktree-list-groups.test.ts | 10 +- .../task-source-context-summary.test.ts | 14 +- .../src/i18n/hosted-review-localized-copy.ts | 24 +- src/renderer/src/i18n/locales/en.json | 27 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- .../lib/project-host-setup-options.test.ts | 15 +- .../src/lib/source-control-generation-plan.ts | 2 +- .../src/runtime/runtime-git-client.ts | 12 +- src/shared/execution-host-registry.test.ts | 8 +- src/shared/execution-host.test.ts | 6 + src/shared/execution-host.ts | 61 +- .../hosted-review-creation-providers.ts | 20 + src/shared/pull-request-generation.test.ts | 13 + src/shared/pull-request-generation.ts | 2 + 64 files changed, 4161 insertions(+), 528 deletions(-) create mode 100644 src/main/azure-devops/pull-request-creation.test.ts create mode 100644 src/main/azure-devops/pull-request-creation.ts create mode 100644 src/main/gitea/pull-request-creation.test.ts create mode 100644 src/main/gitea/pull-request-creation.ts create mode 100644 src/main/source-control/hosted-review-api-request.ts create mode 100644 src/main/source-control/pull-request-template.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-primary-unpublished-action.ts create mode 100644 src/shared/hosted-review-creation-providers.ts 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.', '',