mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,6 +128,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
git: createGitApi(),
|
||||
browser: createBrowserApi(),
|
||||
gh: createGitHubApi(),
|
||||
hostedReview: createRuntimeNamespaceApi('hostedReview'),
|
||||
linear: createRuntimeNamespaceApi('linear'),
|
||||
notes: createNotesApi(),
|
||||
hooks: createHooksApi(),
|
||||
|
||||
@@ -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)\//, '')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user