From 2804249bb93178c9e46d7de06870fd66f00a07eb Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 15 May 2026 12:20:48 -0700 Subject: [PATCH] Add pull request creation flow Co-authored-by: Orca --- src/main/github/client.ts | 223 +++++++++++ src/main/github/gh-utils.ts | 4 +- src/main/ipc/hosted-review.ts | 59 ++- src/main/runtime/orca-runtime.ts | 56 ++- src/main/runtime/rpc/methods/hosted-review.ts | 56 +++ .../hosted-review-creation.test.ts | 206 ++++++++++ .../source-control/hosted-review-creation.ts | 323 ++++++++++++++++ src/preload/api-types.ts | 13 +- src/preload/index.ts | 5 +- .../components/right-sidebar/ChecksPanel.tsx | 211 +++++++++-- .../right-sidebar/CreatePullRequestDialog.tsx | 357 ++++++++++++++++++ .../right-sidebar/SourceControl.tsx | 174 ++++++++- .../source-control-dropdown-items.test.ts | 23 ++ .../source-control-dropdown-items.ts | 58 ++- .../source-control-primary-action.test.ts | 21 ++ .../source-control-primary-action.ts | 26 +- .../src/store/slices/hosted-review.ts | 56 ++- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/hosted-review-refs.ts | 11 + src/shared/hosted-review.ts | 89 ++++- 20 files changed, 1928 insertions(+), 44 deletions(-) create mode 100644 src/main/source-control/hosted-review-creation.test.ts create mode 100644 src/main/source-control/hosted-review-creation.ts create mode 100644 src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx create mode 100644 src/shared/hosted-review-refs.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index a34bd6ff012..5f33dc2466c 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -14,13 +14,22 @@ import type { GitHubViewer, GitHubWorkItem } from '../../shared/types' +import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { sortWorkItemsByUpdatedAt } from '../../shared/work-items' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' import { getPRConflictSummary } from './conflict-summary' import { execFileAsync, ghExecFileAsync, gitExecFileAsync, + extractExecError, acquire, release, getOwnerRepo, @@ -876,6 +885,220 @@ export async function getRepoSlug( return getOwnerRepo(repoPath) } +function classifyCreatePRError(error: unknown): CreateHostedReviewResult { + const { stderr, stdout } = extractExecError(error) + const message = `${stderr}\n${stdout}`.trim() + if (message) { + console.warn('createGitHubPullRequest failed:', message) + } + const lower = message.toLowerCase() + if ( + lower.includes('not logged') || + lower.includes('not authenticated') || + lower.includes('authentication') || + lower.includes('gh auth login') || + lower.includes('http 401') + ) { + return { + ok: false, + code: 'auth_required', + error: + 'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.' + } + } + if (lower.includes('already exists') || lower.includes('a pull request already exists')) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.' + } + } + if (lower.includes('timed out') || lower.includes('timeout')) { + return { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } + if (lower.includes('validation failed') || lower.includes('http 422')) { + return { + ok: false, + code: 'validation', + error: + 'Create PR failed: GitHub rejected the pull request. Check the base branch and branch state, then try again.' + } + } + return { + ok: false, + code: 'unknown', + error: 'Create PR failed: GitHub could not create the pull request. Try again in a moment.' + } +} + +function parseCreatePRPayload(stdout: string): { number: number; url: string } | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { number?: unknown; url?: unknown } + const number = Number(parsed.number) + const url = typeof parsed.url === 'string' ? parsed.url.trim() : '' + if (Number.isInteger(number) && number > 0 && url) { + return { number, url } + } + } catch { + // Fall through to URL parsing for older gh versions without --json support. + } + const urlMatch = trimmed.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/) + if (!urlMatch) { + return null + } + return { number: Number(urlMatch[1]), url: urlMatch[0] } +} + +async function findOpenPRByHeadBase(args: { + repoPath: string + ownerRepo: OwnerRepo + head: string + base: string +}): Promise<{ number: number; url: string } | null> { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'list', + '--repo', + `${args.ownerRepo.owner}/${args.ownerRepo.repo}`, + '--head', + args.head, + '--base', + args.base, + '--state', + 'open', + '--limit', + '2', + '--json', + 'number,url' + ], + { cwd: args.repoPath } + ) + const list = JSON.parse(stdout) as { number?: number; url?: string }[] + if (list.length !== 1 || !list[0]?.number || !list[0]?.url) { + return null + } + return { number: list[0].number, url: list[0].url } +} + +export async function createGitHubPullRequest( + repoPath: string, + input: CreateHostedReviewInput +): Promise { + if (input.provider !== 'github') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating reviews for this provider is not supported yet.' + } + } + + const ownerRepo = await getOwnerRepo(repoPath) + if (!ownerRepo) { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating pull requests requires a GitHub remote.' + } + } + + const base = normalizeHostedReviewBaseRef(input.base) + const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined + const title = input.title.trim() + if (!base || !title) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: base branch and title are required.' + } + } + if (head && head.toLowerCase() === base.toLowerCase()) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: choose a different base branch before creating a pull request.' + } + } + + const tempDir = await mkdtemp(join(tmpdir(), 'orca-pr-body-')) + await acquire() + const bodyPath = join(tempDir, 'body.md') + try { + await writeFile(bodyPath, input.body ?? '', 'utf8') + const createArgs = [ + 'pr', + 'create', + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--base', + base, + '--title', + title, + '--body-file', + bodyPath + ] + if (head) { + createArgs.push('--head', head) + } + if (input.draft) { + createArgs.push('--draft') + } + try { + const { stdout } = await ghExecFileAsync(createArgs, { + cwd: repoPath, + timeout: 60_000, + idempotent: false + }) + const created = parseCreatePRPayload(stdout) + if (created) { + return { ok: true, ...created } + } + const found = head + ? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch(() => null) + : null + if (found) { + return { ok: true, ...found } + } + return { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } catch (error) { + const classified = classifyCreatePRError(error) + if ( + !classified.ok && + (classified.code === 'already_exists' || classified.code === 'unknown_completion') && + head + ) { + const existing = await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch( + () => null + ) + if (existing) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.', + existingReview: existing + } + } + } + return classified + } + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + release() + } +} + export async function getWorkItem( repoPath: string, number: number, diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 48f7afedbf4..9d87a47eab2 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,13 +1,13 @@ import { execFile } from 'child_process' import { promisify } from 'util' -import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' +import { gitExecFileAsync, ghExecFileAsync, extractExecError } from '../git/runner' import type { ClassifiedError, GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' // Why: legacy generic execFile wrapper — only used by callers that don't need // WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers // should use ghExecFileAsync or gitExecFileAsync from the runner instead. export const execFileAsync = promisify(execFile) -export { ghExecFileAsync, gitExecFileAsync } +export { ghExecFileAsync, gitExecFileAsync, extractExecError } // Concurrency limiter - max 4 parallel gh processes const MAX_CONCURRENT = 4 diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index acd89711356..8a69a0d84ac 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -1,9 +1,17 @@ import { ipcMain } from 'electron' import { resolve } from 'path' -import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' +import type { + CreateHostedReviewArgs, + HostedReviewCreationEligibilityArgs, + HostedReviewForBranchArgs +} from '../../shared/hosted-review' import type { Repo } from '../../shared/types' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' +import { + createHostedReview, + getHostedReviewCreationEligibility +} from '../source-control/hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' function assertRegisteredRepo(repoPath: string, store: Store): Repo { @@ -36,4 +44,53 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector } return review }) + + ipcMain.handle( + 'hostedReview:getCreationEligibility', + async (_event, args: HostedReviewCreationEligibilityArgs) => { + const repo = assertRegisteredRepo(args.repoPath, store) + if (repo.connectionId) { + return { + provider: 'unsupported' as const, + review: null, + canCreate: false, + blockedReason: 'unsupported_provider' as const, + nextAction: null, + defaultBaseRef: args.base ?? null, + head: args.branch, + title: null, + body: null + } + } + return getHostedReviewCreationEligibility({ ...args, repoPath: repo.path }) + } + ) + + ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => { + const repo = assertRegisteredRepo(args.repoPath, store) + if (repo.connectionId) { + return { + ok: false as const, + code: 'unsupported_provider' as const, + error: 'Creating pull requests from SSH worktrees is not supported yet.' + } + } + const result = await createHostedReview(repo.path, { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft + }) + if (result.ok && !stats.hasCountedPR(result.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + }) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9f0277b35db..c0e8de6eb2a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -110,8 +110,18 @@ import type { GitHubPRFile, GitHubPRReviewCommentInput } from '../../shared/types' -import type { HostedReviewInfo } from '../../shared/hosted-review' +import type { + CreateHostedReviewInput, + CreateHostedReviewResult, + HostedReviewCreationEligibility, + HostedReviewCreationEligibilityArgs, + HostedReviewInfo +} from '../../shared/hosted-review' import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' +import { + createHostedReview as createHostedReviewFromRepo, + getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo +} from '../source-control/hosted-review-creation' import { connect as connectLinear, disconnect as disconnectLinear, @@ -4478,6 +4488,50 @@ export class OrcaRuntimeService { return review } + async getHostedReviewCreationEligibility( + args: Omit & { repoSelector: string } + ): Promise { + const repo = await this.resolveRepoSelector(args.repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') + return getHostedReviewCreationEligibilityFromRepo({ + repoPath: repo.path, + branch: args.branch, + base: args.base ?? null, + hasUncommittedChanges: args.hasUncommittedChanges, + hasUpstream: args.hasUpstream, + ahead: args.ahead, + behind: args.behind, + linkedGitHubPR: args.linkedGitHubPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null + }) + } + + async createHostedReview( + args: CreateHostedReviewInput & { repoSelector: string } + ): Promise { + const repo = await this.resolveRepoSelector(args.repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') + const result = await createHostedReviewFromRepo(repo.path, { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft + }) + if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + async getRepoIssue( repoSelector: string, number: number diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 3d38f36604a..3416e9939fc 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -11,6 +11,30 @@ const HostedReviewForBranch = z.object({ linkedGiteaPR: z.number().int().positive().nullable().optional() }) +const HostedReviewCreationEligibility = z.object({ + repo: requiredString('Missing repo selector'), + branch: requiredString('Missing branch'), + base: z.string().nullable().optional(), + hasUncommittedChanges: z.boolean().optional(), + hasUpstream: z.boolean().optional(), + ahead: z.number().int().nonnegative().optional(), + behind: z.number().int().nonnegative().optional(), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedGiteaPR: z.number().int().positive().nullable().optional() +}) + +const HostedReviewCreate = z.object({ + repo: requiredString('Missing repo selector'), + provider: z.enum(['github', 'gitlab', 'bitbucket', 'gitea', 'unsupported']), + base: requiredString('Missing base branch'), + head: z.string().optional(), + title: requiredString('Missing title'), + body: z.string().optional(), + draft: z.boolean().optional() +}) + export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ defineMethod({ name: 'hostedReview.forBranch', @@ -24,5 +48,37 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ linkedBitbucketPR: params.linkedBitbucketPR ?? null, linkedGiteaPR: params.linkedGiteaPR ?? null }) + }), + defineMethod({ + name: 'hostedReview.getCreationEligibility', + params: HostedReviewCreationEligibility, + handler: async (params, { runtime }) => + runtime.getHostedReviewCreationEligibility({ + repoSelector: params.repo, + branch: params.branch, + base: params.base ?? null, + hasUncommittedChanges: params.hasUncommittedChanges, + hasUpstream: params.hasUpstream, + ahead: params.ahead, + behind: params.behind, + linkedGitHubPR: params.linkedGitHubPR ?? null, + linkedGitLabMR: params.linkedGitLabMR ?? null, + linkedBitbucketPR: params.linkedBitbucketPR ?? null, + linkedGiteaPR: params.linkedGiteaPR ?? null + }) + }), + defineMethod({ + name: 'hostedReview.create', + params: HostedReviewCreate, + handler: async (params, { runtime }) => + runtime.createHostedReview({ + repoSelector: params.repo, + provider: params.provider, + base: params.base, + head: params.head, + title: params.title, + body: params.body, + draft: params.draft + }) }) ] diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts new file mode 100644 index 00000000000..bf593e57b8a --- /dev/null +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getUpstreamStatusMock: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock +})) + +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' + +describe('createHostedReview', () => { + beforeEach(() => { + createGitHubPullRequestMock.mockReset() + getRepoSlugMock.mockReset() + getProjectSlugMock.mockReset() + getBitbucketRepoSlugMock.mockReset() + getGiteaRepoSlugMock.mockReset() + getHostedReviewForBranchMock.mockReset() + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + getUpstreamStatusMock.mockReset() + + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + getUpstreamStatusMock.mockResolvedValue({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 0 + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'status') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Feature title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- Feature title\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + createGitHubPullRequestMock.mockResolvedValue({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + }) + + it('revalidates ahead commits before creating a GitHub pull request', async () => { + getUpstreamStatusMock.mockResolvedValue({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 1, + behind: 0 + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: 'Create PR failed: push this branch before creating a pull request.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + it('rejects creation when the selected head is no longer checked out', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'other-branch\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: 'Create PR failed: switch back to the selected branch before creating a pull request.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + it('creates the pull request after fresh main-process validation passes', async () => { + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + expect(createGitHubPullRequestMock).toHaveBeenCalledOnce() + }) +}) + +describe('getHostedReviewCreationEligibility', () => { + beforeEach(() => { + createGitHubPullRequestMock.mockReset() + getRepoSlugMock.mockReset() + getProjectSlugMock.mockReset() + getBitbucketRepoSlugMock.mockReset() + getGiteaRepoSlugMock.mockReset() + getHostedReviewForBranchMock.mockReset() + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + getUpstreamStatusMock.mockReset() + + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) + }) + + it('treats short remote base refs as the default branch name', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'main', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'default_branch', + defaultBaseRef: 'origin/main' + }) + }) +}) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts new file mode 100644 index 00000000000..7b8b59bbcfe --- /dev/null +++ b/src/main/source-control/hosted-review-creation.ts @@ -0,0 +1,323 @@ +/* eslint-disable max-lines -- Why: provider detection, eligibility, and creation + preflight share one boundary so renderer and main-process gating cannot drift. */ +import type { + CreateHostedReviewInput, + CreateHostedReviewResult, + HostedReviewCreationBlockedReason, + HostedReviewCreationEligibility, + HostedReviewCreationEligibilityArgs, + HostedReviewProvider +} from '../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' +import { getBitbucketRepoSlug } from '../bitbucket/client' +import { getGiteaRepoSlug } from '../gitea/client' +import { createGitHubPullRequest, getRepoSlug } from '../github/client' +import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' +import { resolveDefaultBaseRefViaExec } from '../git/repo' +import { getUpstreamStatus } from '../git/upstream' +import { getProjectSlug } from '../gitlab/client' +import { getHostedReviewForBranch } from './hosted-review' + +function stripRefPrefix(ref: string): string { + return normalizeHostedReviewHeadRef(ref) +} + +function branchToTitle(branch: string): string { + const lastSegment = branch.split('/').filter(Boolean).at(-1) ?? branch + return lastSegment + .replace(/[-_]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()) +} + +async function detectHostedReviewProvider(repoPath: string): Promise { + if (await getProjectSlug(repoPath)) { + return 'gitlab' + } + if (await getRepoSlug(repoPath)) { + return 'github' + } + if (await getBitbucketRepoSlug(repoPath)) { + return 'bitbucket' + } + if (await getGiteaRepoSlug(repoPath)) { + return 'gitea' + } + return 'unsupported' +} + +async function isGitHubAuthenticated(repoPath: string): Promise { + await acquire() + try { + await ghExecFileAsync(['auth', 'status', '--hostname', 'github.com'], { cwd: repoPath }) + return true + } catch { + return false + } finally { + release() + } +} + +async function getLatestCommitSubject(repoPath: string): Promise { + try { + const { stdout } = await gitExecFileAsync(['log', '-1', '--pretty=%s'], { cwd: repoPath }) + const subject = stdout.trim() + return subject || null + } catch { + return null + } +} + +async function getCommitSummaryBody(repoPath: string, base: string | null): Promise { + if (!base) { + return null + } + try { + const { stdout } = await gitExecFileAsync( + ['log', '--pretty=format:- %s', '--max-count=20', `${base}..HEAD`], + { cwd: repoPath } + ) + const body = stdout.trim() + return body || null + } catch { + return null + } +} + +async function getDefaultBaseRef(repoPath: string): Promise { + return resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: repoPath })) +} + +async function getCurrentBranch(repoPath: string): Promise { + const { stdout } = await gitExecFileAsync(['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: repoPath + }) + return stripRefPrefix(stdout.trim()) +} + +async function hasUncommittedChanges(repoPath: string): Promise { + const { stdout } = await gitExecFileAsync(['status', '--porcelain'], { cwd: repoPath }) + return stdout.trim().length > 0 +} + +const blockedCreateResultByReason = { + auth_required: { + ok: false, + code: 'auth_required', + error: + 'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.' + }, + unsupported_provider: { + ok: false, + code: 'unsupported_provider', + error: 'Creating pull requests requires a GitHub remote.' + }, + dirty: { + ok: false, + code: 'validation', + error: 'Create PR failed: commit or discard local changes before creating a pull request.' + }, + detached_head: { + ok: false, + code: 'validation', + error: 'Create PR failed: switch to a branch before creating a pull request.' + }, + default_branch: { + ok: false, + code: 'validation', + error: 'Create PR failed: choose a feature branch before creating a pull request.' + }, + no_upstream: { + ok: false, + code: 'validation', + error: 'Create PR failed: publish this branch before creating a pull request.' + }, + needs_push: { + ok: false, + code: 'validation', + error: 'Create PR failed: push this branch before creating a pull request.' + }, + needs_sync: { + ok: false, + code: 'validation', + error: 'Create PR failed: sync this branch before creating a pull request.' + }, + fork_head_unsupported: { + ok: false, + code: 'validation', + error: 'Create PR failed: refresh source control status and try again.' + } +} satisfies Partial< + Record, CreateHostedReviewResult> +> + +function blockedEligibilityToCreateResult( + eligibility: HostedReviewCreationEligibility +): CreateHostedReviewResult | null { + if (eligibility.canCreate) { + return null + } + if (eligibility.review?.url) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.', + existingReview: eligibility.review + } + } + if (eligibility.blockedReason) { + return blockedCreateResultByReason[eligibility.blockedReason] ?? null + } + return { + ok: false, + code: 'validation', + error: 'Create PR failed: refresh source control status and try again.' + } +} + +async function validateCurrentBranchCanCreateReview( + repoPath: string, + input: CreateHostedReviewInput +): Promise { + const requestedHead = input.head ? stripRefPrefix(input.head).trim() : '' + const currentBranch = await getCurrentBranch(repoPath) + if (requestedHead && requestedHead !== currentBranch) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: switch back to the selected branch before creating a pull request.' + } + } + + try { + const [dirty, upstreamStatus] = await Promise.all([ + hasUncommittedChanges(repoPath), + getUpstreamStatus(repoPath) + ]) + const eligibility = await getHostedReviewCreationEligibility({ + repoPath, + branch: requestedHead || currentBranch, + base: normalizeHostedReviewBaseRef(input.base), + hasUncommittedChanges: dirty, + hasUpstream: upstreamStatus.hasUpstream, + ahead: upstreamStatus.ahead, + behind: upstreamStatus.behind + }) + // Why: renderer eligibility can be stale by submit time; the main process + // is the last chance to avoid creating a PR from an out-of-date remote head. + return blockedEligibilityToCreateResult(eligibility) + } catch (error) { + console.warn('Hosted review creation preflight failed:', error) + return { + ok: false, + code: 'validation', + error: + 'Create PR failed: could not verify branch status. Refresh source control and try again.' + } + } +} + +export async function getHostedReviewCreationEligibility( + args: HostedReviewCreationEligibilityArgs +): Promise { + const branch = stripRefPrefix(args.branch).trim() + const provider = await detectHostedReviewProvider(args.repoPath) + const defaultBaseRef = args.base?.trim() || (await getDefaultBaseRef(args.repoPath)) + const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null + const review = await getHostedReviewForBranch({ + repoPath: args.repoPath, + branch, + linkedGitHubPR: args.linkedGitHubPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null + }) + + const title = (await getLatestCommitSubject(args.repoPath)) ?? branchToTitle(branch) + const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null) + const baseResult = { + provider, + review: review ? { number: review.number, url: review.url } : null, + defaultBaseRef, + head: branch || null, + title, + body + } + + if (!branch || branch === 'HEAD') { + return { ...baseResult, canCreate: false, blockedReason: 'detached_head', nextAction: null } + } + if (review) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'existing_review', + nextAction: 'open_existing_review' + } + } + if (provider !== 'github') { + return { + ...baseResult, + canCreate: false, + blockedReason: 'unsupported_provider', + nextAction: null + } + } + if (baseBranch && branch.toLowerCase() === baseBranch.toLowerCase()) { + return { ...baseResult, canCreate: false, blockedReason: 'default_branch', nextAction: null } + } + if (args.hasUncommittedChanges) { + return { ...baseResult, canCreate: false, blockedReason: 'dirty', nextAction: 'commit' } + } + if (args.hasUpstream === false) { + return { ...baseResult, canCreate: false, blockedReason: 'no_upstream', nextAction: 'publish' } + } + if (args.hasUpstream !== true) { + return { ...baseResult, canCreate: false, blockedReason: null, nextAction: null } + } + if ((args.behind ?? 0) > 0) { + return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' } + } + if (!(await isGitHubAuthenticated(args.repoPath))) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate' + } + } + if ((args.ahead ?? 0) > 0) { + return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' } + } + return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } +} + +export async function createHostedReview( + repoPath: string, + input: CreateHostedReviewInput +): Promise { + if (input.provider !== 'github') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating reviews for this provider is not supported yet.' + } + } + const provider = await detectHostedReviewProvider(repoPath) + if (provider !== 'github') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating pull requests requires a GitHub remote.' + } + } + const blocked = await validateCurrentBranchCanCreateReview(repoPath, input) + if (blocked) { + return blocked + } + return createGitHubPullRequest(repoPath, input) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cecd0f29da4..417696a6683 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,5 +1,12 @@ /* eslint-disable max-lines -- Why: the preload contract is intentionally centralized in one declaration file so renderer and preload stay in lockstep when IPC surfaces change. */ -import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' +import type { + CreateHostedReviewArgs, + CreateHostedReviewResult, + HostedReviewCreationEligibility, + HostedReviewCreationEligibilityArgs, + HostedReviewForBranchArgs, + HostedReviewInfo +} from '../shared/hosted-review' import type { BaseRefDefaultResult, BrowserCookieImportResult, @@ -779,6 +786,10 @@ export type PreloadApi = { } hostedReview: { forBranch: (args: HostedReviewForBranchArgs) => Promise + getCreationEligibility: ( + args: HostedReviewCreationEligibilityArgs + ) => Promise + create: (args: CreateHostedReviewArgs) => Promise } // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── // Shapes mirror gh.* one-to-one where the data matches; diverge diff --git a/src/preload/index.ts b/src/preload/index.ts index d87d80f2e36..6872bd1e74d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -902,7 +902,10 @@ const api = { hostedReview: { forBranch: (args: HostedReviewForBranchArgs): Promise => - ipcRenderer.invoke('hostedReview:forBranch', args) + ipcRenderer.invoke('hostedReview:forBranch', args), + getCreationEligibility: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:getCreationEligibility', args), + create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args) }, // Why: GitLab bindings live in `./gitlab` so adding or changing a diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 886af2ef3bc..9d00f09d308 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -5,6 +5,7 @@ import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide- import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' import { isFolderRepo } from '../../../../shared/repo-kind' import PRActions from './PRActions' import { @@ -18,6 +19,9 @@ import { import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getConnectionId } from '@/lib/connection-context' +import { CreatePullRequestDialog } from './CreatePullRequestDialog' +import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' export default function ChecksPanel(): React.JSX.Element { const activeWorktree = useActiveWorktree() @@ -25,7 +29,17 @@ export default function ChecksPanel(): React.JSX.Element { const repo = useRepoById(activeWorktree?.repoId ?? null) const prCache = useAppStore((s) => s.prCache) const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) + const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) + const getHostedReviewCreationEligibility = useAppStore( + (s) => s.getHostedReviewCreationEligibility + ) const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) + const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) + const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree) + const pushBranch = useAppStore((s) => s.pushBranch) + const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) + const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) + const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) // Why: the sidebar stays mounted when closed (for performance). Gate // polling on visibility so we don't fetch checks/comments in the background @@ -45,6 +59,10 @@ export default function ChecksPanel(): React.JSX.Element { const [emptyRefreshing, setEmptyRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false) + const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false) + const [createPrPushFirst, setCreatePrPushFirst] = useState(false) + const [hostedReviewCreation, setHostedReviewCreation] = + useState(null) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') const [titleSaving, setTitleSaving] = useState(false) @@ -70,6 +88,8 @@ export default function ChecksPanel(): React.JSX.Element { setIsRefreshing(false) setEmptyRefreshing(false) setConflictDetailsRefreshing(false) + setCreatePrDialogOpen(false) + setCreatePrPushFirst(false) conflictSummaryRefreshKeyRef.current = null } @@ -79,6 +99,10 @@ export default function ChecksPanel(): React.JSX.Element { const prCacheKey = repo && branch ? `${repo.path}::${branch}` : '' const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null const prNumber = pr?.number ?? null + const remoteStatus = activeWorktreeId ? remoteStatusesByWorktree[activeWorktreeId] : undefined + const hasUncommittedChanges = activeWorktreeId + ? (gitStatusByWorktree[activeWorktreeId]?.length ?? 0) > 0 + : false const conflictOperation = activeWorktreeId ? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' @@ -108,6 +132,48 @@ export default function ChecksPanel(): React.JSX.Element { } }, [repo, isFolder, branch, linkedPR, fetchPRForBranch]) + useEffect(() => { + if (!repo || isFolder || !branch || !isPanelVisible) { + setHostedReviewCreation(null) + return + } + let stale = false + void getHostedReviewCreationEligibility({ + repoPath: repo.path, + branch, + base: repo.worktreeBaseRef ?? null, + hasUncommittedChanges, + hasUpstream: remoteStatus?.hasUpstream, + ahead: remoteStatus?.ahead, + behind: remoteStatus?.behind, + linkedGitHubPR: linkedPR + }) + .then((result) => { + if (!stale) { + setHostedReviewCreation(result) + } + }) + .catch(() => { + if (!stale) { + setHostedReviewCreation(null) + } + }) + return () => { + stale = true + } + }, [ + branch, + getHostedReviewCreationEligibility, + hasUncommittedChanges, + isFolder, + isPanelVisible, + linkedPR, + remoteStatus?.ahead, + remoteStatus?.behind, + remoteStatus?.hasUpstream, + repo + ]) + useEffect(() => { if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') { conflictSummaryRefreshKeyRef.current = null @@ -445,6 +511,66 @@ export default function ChecksPanel(): React.JSX.Element { } }, [pr]) + const pushBeforeCreatePullRequest = useCallback(async (): Promise => { + if (!activeWorktreeId || !activeWorktree?.path) { + return false + } + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + try { + await pushBranch( + activeWorktreeId, + activeWorktree.path, + false, + connectionId, + activeWorktree.pushTarget + ) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) + return true + } catch { + return false + } + }, [activeWorktree, activeWorktreeId, fetchUpstreamStatus, pushBranch]) + + const handlePullRequestCreated = useCallback( + async (result: { number: number; url: string }): Promise => { + if (!repo || !branch) { + return + } + setRightSidebarOpen(true) + setRightSidebarTab('checks') + try { + const refreshedPR = await fetchPRForBranch(repo.path, branch, { + force: true, + linkedPRNumber: result.number + }) + await fetchHostedReviewForBranch(repo.path, branch, { + force: true, + linkedGitHubPR: result.number + }) + if (refreshedPR) { + await Promise.all([ + fetchPRChecks(repo.path, refreshedPR.number, branch, refreshedPR.headSha, { + force: true + }).then(setChecks), + fetchPRComments(repo.path, refreshedPR.number, { force: true }).then(setComments) + ]) + } + } catch { + // The success toast keeps the hosted URL available; Checks can be refreshed manually. + } + }, + [ + branch, + fetchHostedReviewForBranch, + fetchPRChecks, + fetchPRComments, + fetchPRForBranch, + repo, + setRightSidebarOpen, + setRightSidebarTab + ] + ) + // ── Empty state ── if (!activeWorktree) { return ( @@ -482,34 +608,67 @@ export default function ChecksPanel(): React.JSX.Element { ? 'Cherry-pick' : null + const canCreate = hostedReviewCreation?.canCreate + const canPushCreate = hostedReviewCreation?.blockedReason === 'needs_push' return ( -
-
- {operationInProgress ? `${operationLabel} in progress` : 'No pull request found'} -
-
- {operationInProgress - ? 'PR checks will be available after the operation completes' - : 'Push your branch and open a PR to see checks here'} -
- {!operationInProgress && ( - + <> + {repo && ( + )} -
+
+
+ {operationInProgress ? `${operationLabel} in progress` : 'No pull request found'} +
+
+ {operationInProgress + ? 'PR checks will be available after the operation completes' + : canPushCreate + ? 'Push your branch before creating a pull request.' + : 'Create a pull request to start checks and review.'} +
+ {!operationInProgress && ( +
+ {(canCreate || canPushCreate) && ( + + )} + +
+ )} +
+ ) } diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx new file mode 100644 index 00000000000..bcfefc5f50d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -0,0 +1,357 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' +import type { + CreateHostedReviewResult, + HostedReviewCreationEligibility +} from '../../../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../../../shared/hosted-review-refs' + +type CreatePullRequestDialogProps = { + open: boolean + repoId: string + repoPath: string + branch: string + eligibility: HostedReviewCreationEligibility | null + pushBeforeCreate: boolean + onOpenChange: (open: boolean) => void + onPushBeforeCreate: () => Promise + onCreated: (result: { number: number; url: string }) => Promise +} + +function stripBaseRef(ref: string): string { + return normalizeHostedReviewBaseRef(ref) +} + +function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string { + if (result.ok) { + return '' + } + if (pushed) { + return `Push succeeded, but PR creation failed: ${result.error.replace(/^Create PR failed:\s*/i, '')}` + } + return result.error +} + +export function CreatePullRequestDialog({ + open, + repoId, + repoPath, + branch, + eligibility, + pushBeforeCreate, + onOpenChange, + onPushBeforeCreate, + onCreated +}: CreatePullRequestDialogProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const createHostedReview = useAppStore((s) => s.createHostedReview) + const submitInFlightRef = useRef(false) + const initializedFromEligibilityRef = useRef(null) + const [base, setBase] = useState('') + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [draft, setDraft] = useState(false) + const [baseQuery, setBaseQuery] = useState('') + const [baseResults, setBaseResults] = useState([]) + const [baseSearchError, setBaseSearchError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) { + submitInFlightRef.current = false + initializedFromEligibilityRef.current = null + setSubmitting(false) + setError(null) + return + } + const initializationKey = `${repoId}:${branch}:${eligibility ? 'ready' : 'pending'}` + if (initializedFromEligibilityRef.current === initializationKey) { + return + } + // Why: eligibility refreshes while the dialog is open; only seed fields + // once per branch so late refreshes do not overwrite user edits. + initializedFromEligibilityRef.current = initializationKey + const initialBase = eligibility?.defaultBaseRef ?? '' + setBase(stripBaseRef(initialBase)) + setTitle(eligibility?.title ?? '') + setBody(eligibility?.body ?? '') + setDraft(false) + setBaseQuery('') + setBaseResults([]) + setBaseSearchError(null) + }, [branch, eligibility, open, repoId]) + + useEffect(() => { + if (!open || base) { + return + } + let stale = false + void getRuntimeRepoBaseRefDefault(settings, repoId) + .then((result) => { + if (!stale && result.defaultBaseRef) { + setBase(stripBaseRef(result.defaultBaseRef)) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [base, open, repoId, settings]) + + useEffect(() => { + if (!open || baseQuery.trim().length < 2) { + setBaseResults([]) + setBaseSearchError(null) + return + } + let stale = false + const timer = window.setTimeout(() => { + void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) + .then((results) => { + if (!stale) { + setBaseResults(results.map(stripBaseRef)) + setBaseSearchError(null) + } + }) + .catch(() => { + if (!stale) { + setBaseResults([]) + setBaseSearchError('Branch discovery failed.') + } + }) + }, 200) + return () => { + stale = true + window.clearTimeout(timer) + } + }, [baseQuery, open, repoId, settings]) + + const submitDisabled = + submitting || + title.trim().length === 0 || + base.trim().length === 0 || + stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() + + const handleSubmit = useCallback(async (): Promise => { + if (submitDisabled || submitInFlightRef.current) { + return + } + submitInFlightRef.current = true + setSubmitting(true) + setError(null) + let pushed = false + try { + if (pushBeforeCreate) { + const ok = await onPushBeforeCreate() + if (!ok) { + setError('Push failed. Resolve the push error, then try again.') + return + } + pushed = true + } + const result = await createHostedReview(repoPath, { + provider: 'github', + base: stripBaseRef(base.trim()), + head: normalizeHostedReviewHeadRef(branch), + title: title.trim(), + body, + draft + }) + if (result.ok) { + toast.success(`Pull request #${result.number} created`, { + action: { + label: 'Open on GitHub', + onClick: () => window.api.shell.openUrl(result.url) + } + }) + await onCreated(result) + onOpenChange(false) + return + } + if (result.existingReview?.url) { + const number = result.existingReview.number + toast.success( + number ? `Pull request #${number} is already open` : 'Pull request is already open', + { + action: { + label: 'Open on GitHub', + onClick: () => window.api.shell.openUrl(result.existingReview!.url) + } + } + ) + if (number) { + await onCreated({ number, url: result.existingReview.url }) + onOpenChange(false) + return + } + } + setError(formatCreateError(result, pushed)) + } finally { + submitInFlightRef.current = false + setSubmitting(false) + } + }, [ + base, + body, + branch, + createHostedReview, + draft, + onCreated, + onOpenChange, + onPushBeforeCreate, + pushBeforeCreate, + repoPath, + submitDisabled, + title + ]) + + const handleOpenChange = useCallback( + (nextOpen: boolean): void => { + // Why: closing during submit would reset the in-flight guard while the + // main-process create call can still complete, allowing duplicate clicks. + if (submitting && !nextOpen) { + return + } + onOpenChange(nextOpen) + }, + [onOpenChange, submitting] + ) + + return ( + + + + Create Pull Request + + Confirm the target branch and PR details before creating the hosted review. + + + +
+
+ +
+ {branch} +
+
+ +
+
+ +

+ Search remote branches or enter a branch name. +

+
+
+ { + setBaseQuery(event.target.value) + setBase(event.target.value) + }} + placeholder="main" + aria-invalid={!base.trim()} + className="pr-8" + /> + +
+ {baseSearchError ?

{baseSearchError}

: null} + {baseResults.length > 0 ? ( +
+ {baseResults.map((ref) => ( + + ))} +
+ ) : null} +
+ +
+ + setTitle(event.target.value)} + aria-invalid={!title.trim()} + /> +
+ +
+ +