Add pull request creation flow

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-15 12:20:48 -07:00
co-authored by Orca
parent 8cca091986
commit 2804249bb9
20 changed files with 1928 additions and 44 deletions
+223
View File
@@ -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<CreateHostedReviewResult> {
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,
+2 -2
View File
@@ -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
+58 -1
View File
@@ -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
})
}
+55 -1
View File
@@ -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<HostedReviewCreationEligibilityArgs, 'repoPath'> & { repoSelector: string }
): Promise<HostedReviewCreationEligibility> {
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<CreateHostedReviewResult> {
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
@@ -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
})
})
]
@@ -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'
})
})
})
@@ -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<HostedReviewProvider> {
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<boolean> {
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<string | null> {
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<string | null> {
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<string | null> {
return resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: repoPath }))
}
async function getCurrentBranch(repoPath: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: repoPath
})
return stripRefPrefix(stdout.trim())
}
async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
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<NonNullable<HostedReviewCreationBlockedReason>, 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<CreateHostedReviewResult | null> {
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<HostedReviewCreationEligibility> {
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<CreateHostedReviewResult> {
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)
}
+12 -1
View File
@@ -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<HostedReviewInfo | null>
getCreationEligibility: (
args: HostedReviewCreationEligibilityArgs
) => Promise<HostedReviewCreationEligibility>
create: (args: CreateHostedReviewArgs) => Promise<CreateHostedReviewResult>
}
// ── GitLab — parallel to gh, MR/issue surface only in v1 ────────
// Shapes mirror gh.* one-to-one where the data matches; diverge
+4 -1
View File
@@ -902,7 +902,10 @@ const api = {
hostedReview: {
forBranch: (args: HostedReviewForBranchArgs): Promise<unknown> =>
ipcRenderer.invoke('hostedReview:forBranch', args)
ipcRenderer.invoke('hostedReview:forBranch', args),
getCreationEligibility: (args: unknown): Promise<unknown> =>
ipcRenderer.invoke('hostedReview:getCreationEligibility', args),
create: (args: unknown): Promise<unknown> => ipcRenderer.invoke('hostedReview:create', args)
},
// Why: GitLab bindings live in `./gitlab` so adding or changing a
@@ -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<HostedReviewCreationEligibility | null>(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<boolean> => {
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<void> => {
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 (
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">
{operationInProgress ? `${operationLabel} in progress` : 'No pull request found'}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{operationInProgress
? 'PR checks will be available after the operation completes'
: 'Push your branch and open a PR to see checks here'}
</div>
{!operationInProgress && (
<button
className="mt-3 px-3 py-1 text-xs font-medium rounded-md border border-border bg-accent/50 text-foreground hover:bg-accent transition-colors disabled:opacity-50"
disabled={emptyRefreshing}
onClick={() => {
if (!activeWorktreeId) {
return
}
setEmptyRefreshing(true)
void handleRefresh().finally(() => {
setEmptyRefreshing(false)
})
}}
>
{emptyRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<>
{repo && (
<CreatePullRequestDialog
open={createPrDialogOpen}
repoId={repo.id}
repoPath={repo.path}
branch={branch}
eligibility={hostedReviewCreation}
pushBeforeCreate={createPrPushFirst}
onOpenChange={setCreatePrDialogOpen}
onPushBeforeCreate={pushBeforeCreatePullRequest}
onCreated={handlePullRequestCreated}
/>
)}
</div>
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">
{operationInProgress ? `${operationLabel} in progress` : 'No pull request found'}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{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.'}
</div>
{!operationInProgress && (
<div className="mt-3 flex flex-wrap gap-2">
{(canCreate || canPushCreate) && (
<Button
size="xs"
onClick={() => {
setCreatePrPushFirst(canPushCreate)
setCreatePrDialogOpen(true)
}}
>
{canPushCreate ? 'Push & Create PR' : 'Create PR'}
</Button>
)}
<Button
size="xs"
variant="outline"
disabled={emptyRefreshing}
onClick={() => {
if (!activeWorktreeId) {
return
}
setEmptyRefreshing(true)
void handleRefresh().finally(() => {
setEmptyRefreshing(false)
})
}}
>
{emptyRefreshing ? 'Refreshing…' : 'Refresh'}
</Button>
</div>
)}
</div>
</>
)
}
@@ -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<boolean>
onCreated: (result: { number: number; url: string }) => Promise<void>
}
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<string | null>(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<string[]>([])
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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<void> => {
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Pull Request</DialogTitle>
<DialogDescription>
Confirm the target branch and PR details before creating the hosted review.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label>Head branch</Label>
<div className="inline-flex max-w-full items-center rounded-full border border-border bg-muted px-2 py-1 text-xs font-medium text-foreground">
<span className="truncate">{branch}</span>
</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="create-pr-base">Base branch</Label>
<p className="text-xs text-muted-foreground">
Search remote branches or enter a branch name.
</p>
</div>
<div className="relative">
<Input
id="create-pr-base"
value={baseQuery || base}
onChange={(event) => {
setBaseQuery(event.target.value)
setBase(event.target.value)
}}
placeholder="main"
aria-invalid={!base.trim()}
className="pr-8"
/>
<ChevronsUpDown className="pointer-events-none absolute right-2 top-2.5 size-3.5 text-muted-foreground" />
</div>
{baseSearchError ? <p className="text-xs text-destructive">{baseSearchError}</p> : null}
{baseResults.length > 0 ? (
<div className="max-h-36 overflow-auto rounded-md border border-border p-1 scrollbar-sleek">
{baseResults.map((ref) => (
<button
key={ref}
type="button"
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent',
stripBaseRef(base) === ref && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setBase(ref)
setBaseQuery('')
setBaseResults([])
}}
>
<span className="truncate">{ref}</span>
{stripBaseRef(base) === ref ? <Check className="size-3" /> : null}
</button>
))}
</div>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="create-pr-title">Title</Label>
<Input
id="create-pr-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
aria-invalid={!title.trim()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-pr-body">Description</Label>
<textarea
id="create-pr-body"
value={body}
onChange={(event) => setBody(event.target.value)}
rows={6}
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={draft}
onChange={(event) => setDraft(event.target.checked)}
className="size-4 rounded border-border accent-primary"
/>
Draft
</label>
{stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() ? (
<p className="text-xs text-destructive">
Choose a different base branch before creating a pull request.
</p>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={() => void handleSubmit()} disabled={submitDisabled}>
{submitting ? <Loader2 className="size-4 animate-spin" /> : null}
{pushBeforeCreate ? 'Push & Create PR' : 'Create PR'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -106,6 +106,7 @@ import {
} from '@/runtime/runtime-git-client'
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import { PullRequestIcon } from './checks-panel-content'
import { CreatePullRequestDialog } from './CreatePullRequestDialog'
import type {
DiffComment,
GitBranchChangeEntry,
@@ -115,7 +116,10 @@ import type {
GitStatusEntry,
GitUpstreamStatus
} from '../../../../shared/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type {
HostedReviewCreationEligibility,
HostedReviewInfo
} from '../../../../shared/hosted-review'
import { STATUS_COLORS, STATUS_LABELS } from './status-display'
type SourceControlScope = 'all' | 'uncommitted'
@@ -149,7 +153,8 @@ const PRIMARY_ICONS: Partial<
stage: Plus,
push: ArrowUp,
sync: ArrowDownUp,
publish: CloudUpload
publish: CloudUpload,
create_pr: GitPullRequestArrow
}
// Why: unstaged ("Changes") is listed first so that conflict files — which
@@ -249,6 +254,10 @@ function SourceControlInner(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const hostedReviewCache = useAppStore((s) => s.hostedReviewCache)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
const getHostedReviewCreationEligibility = useAppStore(
(s) => s.getHostedReviewCreationEligibility
)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const updateRepo = useAppStore((s) => s.updateRepo)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
@@ -272,6 +281,8 @@ function SourceControlInner(): React.JSX.Element {
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
// Why: pass activeWorktreeId directly (even when null/undefined) so the
// slice's getDiffComments returns its stable EMPTY_COMMENTS sentinel. An
// inline `[]` fallback would allocate a new array each store update, break
@@ -349,6 +360,10 @@ function SourceControlInner(): React.JSX.Element {
const [generateErrors, setGenerateErrors] = useState<Record<string, string | null>>({})
const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const generateError = generateErrors[activeWorktreeId ?? ''] ?? null
const [hostedReviewCreation, setHostedReviewCreation] =
useState<HostedReviewCreationEligibility | null>(null)
const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false)
const [createPrPushFirst, setCreatePrPushFirst] = useState(false)
const commitMessageAi = useAppStore((s) => s.settings?.commitMessageAi)
const filterInputRef = useRef<HTMLInputElement>(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
@@ -492,6 +507,52 @@ function SourceControlInner(): React.JSX.Element {
linkedGitLabMR
])
useEffect(() => {
if (!isBranchVisible || !activeRepo || isFolder || !branchName) {
setHostedReviewCreation(null)
return
}
let stale = false
void getHostedReviewCreationEligibility({
repoPath: activeRepo.path,
branch: branchName,
base: effectiveBaseRef ?? null,
hasUncommittedChanges: hasUncommittedEntries,
hasUpstream: remoteStatus?.hasUpstream,
ahead: remoteStatus?.ahead,
behind: remoteStatus?.behind,
linkedGitHubPR,
linkedGitLabMR
})
.then((result) => {
if (!stale) {
setHostedReviewCreation(result)
}
})
.catch((error) => {
console.warn('[SourceControl] hosted review creation eligibility failed', error)
if (!stale) {
setHostedReviewCreation(null)
}
})
return () => {
stale = true
}
}, [
activeRepo,
branchName,
effectiveBaseRef,
getHostedReviewCreationEligibility,
hasUncommittedEntries,
isBranchVisible,
isFolder,
linkedGitHubPR,
linkedGitLabMR,
remoteStatus?.ahead,
remoteStatus?.behind,
remoteStatus?.hasUpstream
])
const grouped = useMemo(() => {
const groups = {
staged: [] as GitStatusEntry[],
@@ -617,6 +678,8 @@ function SourceControlInner(): React.JSX.Element {
// repos and back to re-trigger the resolver.
setFilterQuery('')
setIsExecutingBulk(false)
setCreatePrDialogOpen(false)
setCreatePrPushFirst(false)
// Why: no reset for commit-in-flight state — it now lives in a per-worktree
// map, so it cannot leak across worktrees. Resetting here would actually
// clear in-flight state for the *incoming* worktree if the user is coming
@@ -882,6 +945,76 @@ function SourceControlInner(): React.JSX.Element {
[handleCommit, runRemoteAction]
)
const openCreatePullRequestDialog = useCallback((pushFirst: boolean): void => {
setCreatePrPushFirst(pushFirst)
setCreatePrDialogOpen(true)
}, [])
const pushBeforeCreatePullRequest = useCallback(async (): Promise<boolean> => {
if (!activeWorktreeId || !worktreePath) {
return false
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
try {
await pushBranch(
activeWorktreeId,
worktreePath,
false,
connectionId,
activeWorktree?.pushTarget
)
await refreshActiveGitStatusAfterMutation()
return true
} catch {
return false
}
}, [
activeWorktree?.pushTarget,
activeWorktreeId,
pushBranch,
refreshActiveGitStatusAfterMutation,
worktreePath
])
const handlePullRequestCreated = useCallback(
async (result: { number: number; url: string }): Promise<void> => {
if (!activeRepo || !branchName) {
return
}
setRightSidebarOpen(true)
setRightSidebarTab('checks')
try {
await Promise.all([
fetchHostedReviewForBranch(activeRepo.path, branchName, {
force: true,
linkedGitHubPR: result.number,
linkedGitLabMR
}),
fetchPRForBranch(activeRepo.path, branchName, {
force: true,
linkedPRNumber: result.number
})
])
} catch {
toast.warning('Pull request created, but Orca could not refresh it yet.', {
action: {
label: 'Open on GitHub',
onClick: () => window.api.shell.openUrl(result.url)
}
})
}
},
[
activeRepo,
branchName,
fetchHostedReviewForBranch,
fetchPRForBranch,
linkedGitLabMR,
setRightSidebarOpen,
setRightSidebarTab
]
)
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
const primaryAction: PrimaryAction = useMemo(
@@ -894,7 +1027,8 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
upstreamStatus: remoteStatus,
inFlightRemoteOpKind
inFlightRemoteOpKind,
hostedReviewCreation
}),
[
commitMessage,
@@ -903,6 +1037,7 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
inFlightRemoteOpKind,
hostedReviewCreation,
remoteStatus,
unresolvedConflicts.length
]
@@ -918,7 +1053,8 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
upstreamStatus: remoteStatus,
inFlightRemoteOpKind
inFlightRemoteOpKind,
hostedReviewCreation
}),
[
commitMessage,
@@ -927,6 +1063,7 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
inFlightRemoteOpKind,
hostedReviewCreation,
remoteStatus,
unresolvedConflicts.length
]
@@ -948,6 +1085,12 @@ function SourceControlInner(): React.JSX.Element {
case 'commit_sync':
void runCompoundCommitAction('sync')
return
case 'create_pr':
openCreatePullRequestDialog(false)
return
case 'push_create_pr':
openCreatePullRequestDialog(true)
return
case 'push':
case 'pull':
case 'sync':
@@ -964,7 +1107,7 @@ function SourceControlInner(): React.JSX.Element {
}
}
},
[handleCommit, runCompoundCommitAction, runRemoteAction]
[handleCommit, openCreatePullRequestDialog, runCompoundCommitAction, runRemoteAction]
)
const handleOpenDiff = useCallback(
@@ -1220,6 +1363,7 @@ function SourceControlInner(): React.JSX.Element {
case 'pull':
case 'sync':
case 'publish':
case 'create_pr':
handleActionInvoke(primaryAction.kind)
return
default: {
@@ -1746,6 +1890,17 @@ function SourceControlInner(): React.JSX.Element {
return (
<>
<CreatePullRequestDialog
open={createPrDialogOpen}
repoId={activeRepo.id}
repoPath={activeRepo.path}
branch={branchName}
eligibility={hostedReviewCreation}
pushBeforeCreate={createPrPushFirst}
onOpenChange={setCreatePrDialogOpen}
onPushBeforeCreate={pushBeforeCreatePullRequest}
onCreated={handlePullRequestCreated}
/>
<div ref={sourceControlRef} className="relative flex h-full flex-col overflow-hidden">
<div className="flex items-center px-3 pt-2 border-b border-border">
{(['all', 'uncommitted'] as const).map((value) => (
@@ -2524,7 +2679,14 @@ export function CommitArea({
onDropdownAction(entry.kind)
}}
>
{entry.label}
<span className="flex min-w-0 flex-col">
<span>{entry.label}</span>
{entry.hint ? (
<span className="truncate text-[10px] text-muted-foreground">
{entry.hint}
</span>
) : null}
</span>
</DropdownMenuItem>
)
)}
@@ -33,6 +33,8 @@ describe('resolveDropdownItems', () => {
'commit_sync',
'separator',
'push',
'create_pr',
'push_create_pr',
'pull',
'sync',
'fetch',
@@ -188,4 +190,25 @@ describe('resolveDropdownItems', () => {
expect(byKind.push.label).toBe('Push (2)')
expect(byKind.sync.label).toBe('Sync (↓3 ↑2)')
})
it('enables Push & Create PR when review creation is only blocked by unpushed commits', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
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.disabled).toBe(true)
expect(byKind.create_pr.hint).toBe('Push first')
expect(byKind.push_create_pr.disabled).toBe(false)
})
})
@@ -6,6 +6,8 @@ export type DropdownActionKind =
| 'commit'
| 'commit_push'
| 'commit_sync'
| 'create_pr'
| 'push_create_pr'
| 'push'
| 'pull'
| 'sync'
@@ -17,6 +19,7 @@ export type DropdownItem = {
label: string
title: string
disabled: boolean
hint?: string
}
export type DropdownSeparator = { kind: 'separator' }
@@ -58,7 +61,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
hasUnresolvedConflicts,
isCommitting,
isRemoteOperationActive,
upstreamStatus
upstreamStatus,
hostedReviewCreation
} = inputs
const hasStaged = stagedCount > 0
@@ -198,12 +202,64 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
disabled: globalBusy || upstreamLoading || hasUpstream
}
const createBlockedHint = (() => {
switch (hostedReviewCreation?.blockedReason) {
case 'dirty':
return 'Commit changes first'
case 'detached_head':
return 'Check out a branch first'
case 'default_branch':
return 'Switch to a feature branch'
case 'no_upstream':
return 'Publish Branch'
case 'needs_push':
return 'Push first'
case 'needs_sync':
return 'Sync first'
case 'auth_required':
return 'Run gh auth login in this environment'
case 'unsupported_provider':
return 'Unsupported provider'
case 'existing_review':
return 'A pull request already exists'
case 'fork_head_unsupported':
return 'Fork head unsupported'
default:
return upstreamLoading ? 'Checking branch status…' : 'Branch is not ready'
}
})()
const createPRItem: DropdownItem = {
kind: 'create_pr',
label: 'Create PR',
title: hostedReviewCreation?.canCreate
? 'Create a pull request for this branch'
: createBlockedHint,
hint: hostedReviewCreation?.canCreate ? undefined : createBlockedHint,
disabled: globalBusy || upstreamLoading || !hostedReviewCreation?.canCreate
}
const canPushAndCreate =
!globalBusy &&
!upstreamLoading &&
hostedReviewCreation?.provider === 'github' &&
hostedReviewCreation.blockedReason === 'needs_push'
const pushCreatePRItem: DropdownItem = {
kind: 'push_create_pr',
label: 'Push & Create PR',
title: canPushAndCreate ? 'Push local commits, then create a pull request' : createBlockedHint,
hint: canPushAndCreate ? undefined : createBlockedHint,
disabled: !canPushAndCreate
}
return [
commitItem,
commitPushItem,
commitSyncItem,
{ kind: 'separator' },
pushItem,
createPRItem,
pushCreatePRItem,
pullItem,
syncItem,
fetchItem,
@@ -325,4 +325,25 @@ describe('resolvePrimaryAction', () => {
disabled: true
})
})
it('returns Create PR when a clean tracked branch is eligible for review creation', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
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
})
})
})
@@ -1,5 +1,6 @@
// Why: split from the combined primary+dropdown module because the primary and dropdown are independent derivations with different priority ladders; together they exceed the max-lines budget and tangle unrelated concerns.
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import type { GitUpstreamStatus } from '../../../../shared/types'
// Why: this module owns the pure state-machine logic for the Source Control
@@ -14,7 +15,14 @@ import type { GitUpstreamStatus } from '../../../../shared/types'
// `handlePrimaryClick` switch exhaustively over only the kinds the
// primary can actually emit, and it kills the compound-commit branch in
// the isRemoteOperationActive tooltip below at compile time.
export type PrimaryActionKind = 'commit' | 'stage' | 'push' | 'pull' | 'sync' | 'publish'
export type PrimaryActionKind =
| 'commit'
| 'stage'
| 'push'
| 'pull'
| 'sync'
| 'publish'
| 'create_pr'
// Why: the in-flight remote op tracker stores which action the user actually
// triggered, so the primary button can mirror that label/spinner instead of
@@ -44,6 +52,7 @@ export type PrimaryActionInputs = {
// the user-triggered action on the primary button instead of leaving a
// stale label that no longer matches what the slice is doing.
inFlightRemoteOpKind?: RemoteOpKind | null
hostedReviewCreation?: HostedReviewCreationEligibility | null
}
const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string> = {
@@ -51,7 +60,8 @@ const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string
push: 'Push',
pull: 'Pull',
sync: 'Sync',
publish: 'Publish Branch'
publish: 'Publish Branch',
create_pr: 'Create PR'
}
function describePushCount(ahead: number): string {
@@ -93,7 +103,8 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
isCommitting,
isRemoteOperationActive,
upstreamStatus,
inFlightRemoteOpKind
inFlightRemoteOpKind,
hostedReviewCreation
} = inputs
// 1. Commit in flight — lock the primary no matter what else is true.
@@ -247,6 +258,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
}
if (hostedReviewCreation?.canCreate) {
return {
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
}
}
// Clean + tracked + in sync — distinguish truly clean from work that still
// needs staging before commit can proceed.
return {
+55 -1
View File
@@ -1,5 +1,11 @@
import type { StateCreator } from 'zustand'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type {
CreateHostedReviewInput,
CreateHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewInfo
} from '../../../../shared/hosted-review'
import type { GlobalSettings } from '../../../../shared/types'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { AppState } from '../types'
@@ -31,6 +37,13 @@ export function getHostedReviewCacheKey(
export type HostedReviewSlice = {
hostedReviewCache: Record<string, CacheEntry<HostedReviewInfo>>
getHostedReviewCreationEligibility: (
args: HostedReviewCreationEligibilityArgs
) => Promise<HostedReviewCreationEligibility>
createHostedReview: (
repoPath: string,
input: CreateHostedReviewInput
) => Promise<CreateHostedReviewResult>
fetchHostedReviewForBranch: (
repoPath: string,
branch: string,
@@ -49,6 +62,47 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
) => ({
hostedReviewCache: {},
getHostedReviewCreationEligibility: async (args) => {
const settings = get().settings
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'environment') {
const repo = get().repos.find((candidate) => candidate.path === args.repoPath)
const { repoPath: _repoPath, ...runtimeArgs } = args
void _repoPath
return callRuntimeRpc<HostedReviewCreationEligibility>(
target,
'hostedReview.getCreationEligibility',
{ repo: repo?.id ?? args.repoPath, ...runtimeArgs },
{ timeoutMs: 30_000 }
)
}
const repo = get().repos.find((candidate) => candidate.path === args.repoPath)
return window.api.hostedReview.getCreationEligibility({
...args,
connectionId: repo?.connectionId ?? null
})
},
createHostedReview: async (repoPath, input) => {
const settings = get().settings
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'environment') {
const repo = get().repos.find((candidate) => candidate.path === repoPath)
return callRuntimeRpc<CreateHostedReviewResult>(
target,
'hostedReview.create',
{ repo: repo?.id ?? repoPath, ...input },
{ timeoutMs: 60_000 }
)
}
const repo = get().repos.find((candidate) => candidate.path === repoPath)
return window.api.hostedReview.create({
repoPath,
connectionId: repo?.connectionId ?? null,
...input
})
},
fetchHostedReviewForBranch: async (
repoPath,
branch,
+1
View File
@@ -128,6 +128,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
git: createGitApi(),
browser: createBrowserApi(),
gh: createGitHubApi(),
hostedReview: createRuntimeNamespaceApi('hostedReview'),
linear: createRuntimeNamespaceApi('linear'),
notes: createNotesApi(),
hooks: createHooksApi(),
+11
View File
@@ -0,0 +1,11 @@
export function normalizeHostedReviewHeadRef(ref: string): string {
return ref
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^refs\/remotes\/[^/]+\//, '')
}
export function normalizeHostedReviewBaseRef(ref: string): string {
const normalized = normalizeHostedReviewHeadRef(ref)
return normalized.replace(/^(origin|upstream)\//, '')
}
+88 -1
View File
@@ -1,6 +1,6 @@
import type { CheckStatus, PRConflictSummary, PRMergeableState } from './types'
export type HostedReviewProvider = 'github' | 'gitlab' | 'bitbucket' | 'gitea'
export type HostedReviewProvider = 'github' | 'gitlab' | 'bitbucket' | 'gitea' | 'unsupported'
export type HostedReviewState = 'open' | 'closed' | 'merged' | 'draft'
@@ -25,3 +25,90 @@ export type HostedReviewForBranchArgs = {
linkedBitbucketPR?: number | null
linkedGiteaPR?: number | null
}
export type HostedReviewSummary = {
number?: number
url: string
}
export type CreateHostedReviewInput = {
provider: HostedReviewProvider
base: string
head?: string
title: string
body?: string
draft?: boolean
}
export type CreateHostedReviewArgs = CreateHostedReviewInput & {
repoPath: string
connectionId?: string | null
}
export type CreateHostedReviewErrorCode =
| 'auth_required'
| 'unsupported_provider'
| 'already_exists'
| 'validation'
| 'timeout'
| 'unknown_completion'
| 'push_failed'
| 'unknown'
export type CreateHostedReviewResult =
| { ok: true; number: number; url: string }
| {
ok: false
code: CreateHostedReviewErrorCode
error: string
existingReview?: HostedReviewSummary
}
export type HostedReviewCreationBlockedReason =
| 'dirty'
| 'detached_head'
| 'default_branch'
| 'no_upstream'
| 'needs_push'
| 'needs_sync'
| 'auth_required'
| 'fork_head_unsupported'
| 'unsupported_provider'
| 'existing_review'
| null
export type HostedReviewCreationNextAction =
| 'commit'
| 'publish'
| 'push'
| 'sync'
| 'authenticate'
| 'open_existing_review'
| null
export type HostedReviewCreationEligibility = {
provider: HostedReviewProvider
review: HostedReviewSummary | null
canCreate: boolean
blockedReason: HostedReviewCreationBlockedReason
nextAction: HostedReviewCreationNextAction
defaultBaseRef?: string | null
head?: string | null
title?: string | null
body?: string | null
}
export type HostedReviewCreationEligibilityArgs = {
repoPath: string
connectionId?: string | null
branch: string
base?: string | null
hasUncommittedChanges?: boolean
hasUpstream?: boolean
ahead?: number
behind?: number
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedGiteaPR?: number | null
}