mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
* Clarify PR panel guidance: classify errors and confirm-only composer Replace the ambiguous GitHub hosted-review boolean with a four-state evidence model (found/positive_unresolved/not_found/unknown) so "No PR found" never appears without an accepted lookup result. Classify GitHub refresh failures into types (rate_limited, auth, network, permission, repo_unavailable, gh_unavailable, unknown) for stable, honest copy. Confirmed-only composer: preserve drafts across transient failures; hide Create during hard errors and positive-unresolved evidence. Hard errors clear only when an eligibility request starts after the error and returns an accepted outcome. Propagate error types and unified retry schedule through the store. Sync mobile parity with shouldOpenChecksPanelCreateComposer gating. Localize all new copy. * Clarify PR panel guidance: classify errors and confirm-only composer Add reviewLookupOutcome to hosted-review eligibility and thread it through the panel so it never claims "No PR found" without accepted evidence. A failed lookup is unavailable, not a settled no-PR. Fail closed on positive unresolved evidence, hard refresh errors, and unavailable lookups. Add structured GitHub refresh-error classification with Retry-After parsing. Implement confirmed-only composer gating based on fresh, matching-context eligibility with hard-error clearing. Mobile gates on reviewLookupOutcome to prevent false Create claims. Surface throwOnFailure variants for each provider so transport failures cross the RPC boundary instead of collapsing to null. (Design success criteria 1–4; invariant 8.) * Add exec-error helpers for subprocess error classification Extracts stderr/stdout parsing and Retry-After detection into a lightweight module that can be imported without pulling in the heavier runner machinery. Supports PR-refresh error classification and proper rate-limit handling for gh commands. * test(mobile): include reviewLookupOutcome in create eligibility fixtures Create / Push & Create now fails closed unless the lookup is not_found. Update mobile test fixtures so accepted-no-PR cases can still proceed. * Add OrThrow mock variants to forge-provider test mocks forge-provider resolves branch reviews via the OrThrow variant so lookup failures surface as unavailable instead of "no PR found".
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
|
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
|
|
import { shouldOpenChecksPanelCreateComposer } from '../../../src/renderer/src/components/right-sidebar/checks-panel-review-creation'
|
|
import {
|
|
buildMobilePrCreateParams,
|
|
getMobilePrCreateBlockMessage,
|
|
mobileRepoSelectorFromWorktreeId,
|
|
resolveMobilePrPrefill,
|
|
shouldPushBeforeMobilePrCreate,
|
|
type MobilePrPrefill
|
|
} from './mobile-pr-create'
|
|
|
|
function ok(result: unknown): RpcSuccess {
|
|
return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
|
|
}
|
|
function fail(message: string): RpcFailure {
|
|
return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
|
|
}
|
|
function clientWith(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
|
|
calls: Array<{ method: string; params: unknown }>
|
|
} {
|
|
const calls: Array<{ method: string; params: unknown }> = []
|
|
return {
|
|
calls,
|
|
sendRequest: vi.fn(async (method: string, params?: unknown) => {
|
|
calls.push({ method, params })
|
|
return responses.shift() ?? fail('unexpected')
|
|
})
|
|
}
|
|
}
|
|
|
|
function eligibility(
|
|
overrides: Partial<HostedReviewCreationEligibility> = {}
|
|
): HostedReviewCreationEligibility {
|
|
return {
|
|
provider: 'github',
|
|
review: null,
|
|
canCreate: true,
|
|
blockedReason: null,
|
|
nextAction: null,
|
|
reviewLookupOutcome: 'not_found',
|
|
defaultBaseRef: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('mobileRepoSelectorFromWorktreeId', () => {
|
|
it('extracts the repo id before the :: separator', () => {
|
|
expect(mobileRepoSelectorFromWorktreeId('repo-1::/tmp/wt')).toBe('id:repo-1')
|
|
expect(mobileRepoSelectorFromWorktreeId('repo-1')).toBe('id:repo-1')
|
|
})
|
|
})
|
|
|
|
describe('buildMobilePrCreateParams', () => {
|
|
it('trims fields and drops empty optionals', () => {
|
|
expect(
|
|
buildMobilePrCreateParams('repo-1::/tmp/wt', {
|
|
provider: 'github',
|
|
base: ' main ',
|
|
title: ' Add feature ',
|
|
body: ' ',
|
|
draft: false,
|
|
useTemplate: true
|
|
})
|
|
).toEqual({
|
|
repo: 'id:repo-1',
|
|
worktree: 'id:repo-1::/tmp/wt',
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'Add feature',
|
|
draft: false,
|
|
useTemplate: true
|
|
})
|
|
})
|
|
|
|
it('keeps a non-empty body and head', () => {
|
|
const params = buildMobilePrCreateParams('repo-1::/tmp/wt', {
|
|
provider: 'gitlab',
|
|
base: 'main',
|
|
head: 'feature/x',
|
|
title: 'T',
|
|
body: 'Body text',
|
|
draft: true
|
|
})
|
|
expect(params).toMatchObject({ head: 'feature/x', body: 'Body text', draft: true })
|
|
})
|
|
})
|
|
|
|
describe('mobile create form gating parity', () => {
|
|
it.each([
|
|
{ reason: null, canCreate: true },
|
|
{ reason: 'dirty', canCreate: false },
|
|
{ reason: 'detached_head', canCreate: false },
|
|
{ reason: 'default_branch', canCreate: false },
|
|
{ reason: 'no_upstream', canCreate: false },
|
|
{ reason: 'needs_push', canCreate: false },
|
|
{ reason: 'needs_sync', canCreate: false },
|
|
{ reason: 'auth_required', canCreate: false },
|
|
{ reason: 'unsupported_provider', canCreate: false },
|
|
{ reason: 'existing_review', canCreate: false },
|
|
{ reason: 'fork_head_unsupported', canCreate: false }
|
|
] as const)('matches desktop composer gating for $reason', ({ reason, canCreate }) => {
|
|
const desktopEligibility = eligibility({ canCreate, blockedReason: reason })
|
|
const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({
|
|
activeReview: null,
|
|
isFolder: false,
|
|
branch: 'feature/x',
|
|
hostedReviewCreation: desktopEligibility
|
|
})
|
|
const mobileAllowsComposer =
|
|
getMobilePrCreateBlockMessage({
|
|
provider: desktopEligibility.provider,
|
|
base: desktopEligibility.defaultBaseRef ?? 'main',
|
|
title: desktopEligibility.title ?? 'feature/x',
|
|
body: desktopEligibility.body ?? '',
|
|
canCreate: desktopEligibility.canCreate,
|
|
blockedReason: desktopEligibility.blockedReason,
|
|
nextAction: desktopEligibility.nextAction,
|
|
// Mobile receives the lookup outcome from eligibility; thread it so the
|
|
// gate reflects real prefills (current hosts always populate it).
|
|
reviewLookupOutcome: desktopEligibility.reviewLookupOutcome
|
|
}) === null
|
|
|
|
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
|
|
})
|
|
|
|
it('fails closed when the review-lookup outcome is missing (older host)', () => {
|
|
// A host that predates `reviewLookupOutcome` leaves review existence unproven.
|
|
// Mobile must not open Create / Push & Create on that ambiguity.
|
|
expect(
|
|
getMobilePrCreateBlockMessage({
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
canCreate: true,
|
|
blockedReason: null
|
|
})
|
|
).toBe(
|
|
'Orca could not confirm whether this branch already has a pull request. Try again in a moment.'
|
|
)
|
|
expect(
|
|
getMobilePrCreateBlockMessage({
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
canCreate: false,
|
|
blockedReason: 'needs_push'
|
|
})
|
|
).toBe(
|
|
'Orca could not confirm whether this branch already has a pull request. Try again in a moment.'
|
|
)
|
|
})
|
|
|
|
it('desktop gate hard-blocks on positive unresolved review evidence', () => {
|
|
// Mobile lacks review-lookup signals, so it fails closed on ambiguity: the
|
|
// shared desktop gate must return false even when eligibility looks ready.
|
|
expect(
|
|
shouldOpenChecksPanelCreateComposer({
|
|
activeReview: null,
|
|
isFolder: false,
|
|
branch: 'feature/x',
|
|
hostedReviewCreation: eligibility({ canCreate: true }),
|
|
reviewLookup: 'positive_unresolved'
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('desktop gate hard-blocks during a hard refresh error', () => {
|
|
expect(
|
|
shouldOpenChecksPanelCreateComposer({
|
|
activeReview: null,
|
|
isFolder: false,
|
|
branch: 'feature/x',
|
|
hostedReviewCreation: eligibility({ canCreate: true }),
|
|
hasHardRefreshError: true
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('fails closed on an unavailable review lookup even when eligibility looks ready', () => {
|
|
// The existing-review lookup could not prove there is no PR; mobile has no
|
|
// review-lookup signal of its own, so create must be blocked.
|
|
expect(
|
|
getMobilePrCreateBlockMessage({
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
canCreate: true,
|
|
blockedReason: null,
|
|
reviewLookupOutcome: 'unavailable'
|
|
})
|
|
).toBe(
|
|
'Orca could not confirm whether this branch already has a pull request. Try again in a moment.'
|
|
)
|
|
})
|
|
|
|
it('fails closed on unavailable even on the needs_push Push & Create path', () => {
|
|
// needs_push would normally be allowed (Push & Create); an unavailable lookup
|
|
// must still block it — this is the fail-open gap the parity gate closes.
|
|
const mobileBlocked =
|
|
getMobilePrCreateBlockMessage({
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
canCreate: false,
|
|
blockedReason: 'needs_push',
|
|
reviewLookupOutcome: 'unavailable'
|
|
}) !== null
|
|
const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({
|
|
activeReview: null,
|
|
isFolder: false,
|
|
branch: 'feature/x',
|
|
hostedReviewCreation: eligibility({
|
|
canCreate: false,
|
|
blockedReason: 'needs_push',
|
|
reviewLookupOutcome: 'unavailable'
|
|
})
|
|
})
|
|
expect(mobileBlocked).toBe(true)
|
|
expect(desktopAllowsComposer).toBe(false)
|
|
})
|
|
|
|
it('stays safely blocked for a reason added by a newer desktop contract', () => {
|
|
expect(
|
|
getMobilePrCreateBlockMessage({
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
canCreate: false,
|
|
blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason']
|
|
})
|
|
).toBe('This branch is not ready for a pull request yet.')
|
|
})
|
|
})
|
|
|
|
describe('resolveMobilePrPrefill', () => {
|
|
const baseArgs = {
|
|
branch: 'feature/x',
|
|
title: 'feature/x',
|
|
hasUncommittedChanges: false,
|
|
hasUpstream: true,
|
|
ahead: 1,
|
|
behind: 0
|
|
}
|
|
|
|
it('derives provider/base/title/body from eligibility (non-GitHub honored)', async () => {
|
|
const client = clientWith([
|
|
ok({
|
|
provider: 'gitlab',
|
|
canCreate: true,
|
|
review: null,
|
|
blockedReason: null,
|
|
nextAction: null,
|
|
defaultBaseRef: 'develop',
|
|
title: 'Add feature',
|
|
body: 'Body',
|
|
reviewLookupOutcome: 'not_found'
|
|
})
|
|
])
|
|
await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
|
|
provider: 'gitlab',
|
|
base: 'develop',
|
|
title: 'Add feature',
|
|
body: 'Body',
|
|
canCreate: true,
|
|
blockedReason: null,
|
|
nextAction: null,
|
|
reviewLookupOutcome: 'not_found'
|
|
})
|
|
})
|
|
|
|
it('marks needs_push eligibility for submit-time push parity', async () => {
|
|
const client = clientWith([
|
|
ok({
|
|
provider: 'github',
|
|
canCreate: false,
|
|
review: null,
|
|
blockedReason: 'needs_push',
|
|
nextAction: 'push',
|
|
defaultBaseRef: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
reviewLookupOutcome: 'not_found'
|
|
})
|
|
])
|
|
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
|
expect(shouldPushBeforeMobilePrCreate(prefill)).toBe(true)
|
|
expect(getMobilePrCreateBlockMessage(prefill)).toBeNull()
|
|
})
|
|
|
|
it('returns a mobile block message for desktop-blocked create states', async () => {
|
|
const client = clientWith([
|
|
ok({
|
|
provider: 'github',
|
|
canCreate: false,
|
|
review: null,
|
|
blockedReason: 'dirty',
|
|
nextAction: 'commit',
|
|
defaultBaseRef: 'main',
|
|
reviewLookupOutcome: 'not_found'
|
|
})
|
|
])
|
|
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
|
expect(getMobilePrCreateBlockMessage(prefill)).toBe(
|
|
'Commit changes before creating a pull request.'
|
|
)
|
|
})
|
|
|
|
it('returns a blocked fallback when eligibility is unavailable', async () => {
|
|
const client = clientWith([fail('nope')])
|
|
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
|
expect(prefill).toEqual({
|
|
provider: 'github',
|
|
base: 'main',
|
|
title: 'feature/x',
|
|
body: '',
|
|
canCreate: false,
|
|
blockedReason: null,
|
|
nextAction: null,
|
|
// Eligibility could not be resolved, so the review lookup is unproven.
|
|
reviewLookupOutcome: 'unavailable'
|
|
})
|
|
// A prefill Orca could not resolve must not offer create.
|
|
expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull()
|
|
})
|
|
|
|
it('threads reviewLookupOutcome from eligibility into the prefill and blocks needs_push', async () => {
|
|
const client = clientWith([
|
|
ok({
|
|
provider: 'github',
|
|
canCreate: false,
|
|
review: null,
|
|
blockedReason: 'needs_push',
|
|
nextAction: 'push',
|
|
defaultBaseRef: 'main',
|
|
title: 'Add feature',
|
|
body: '',
|
|
reviewLookupOutcome: 'unavailable'
|
|
})
|
|
])
|
|
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
|
expect(prefill.reviewLookupOutcome).toBe('unavailable')
|
|
expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull()
|
|
})
|
|
|
|
it('blocks without calling the RPC when there is no branch', async () => {
|
|
const client = clientWith([])
|
|
const result = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', {
|
|
...baseArgs,
|
|
branch: undefined
|
|
})
|
|
expect(result.provider).toBe('github')
|
|
expect(result.canCreate).toBe(false)
|
|
expect(result.blockedReason).toBe('detached_head')
|
|
expect(client.calls).toEqual([])
|
|
})
|
|
})
|