Files
orca/src/shared/hosted-review.ts
T
Jinjing d67ede1594 Implement confirm-only PR panel composer with classified error blocking (#9428)
* 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".
2026-07-19 16:32:36 -07:00

215 lines
5.5 KiB
TypeScript

import type { CheckStatus, PRConflictSummary, PRMergeableState, PRReviewDecision } from './types'
export type HostedReviewProvider =
| 'github'
| 'gitlab'
| 'bitbucket'
| 'azure-devops'
| 'gitea'
| 'unsupported'
export type HostedReviewState = 'open' | 'closed' | 'merged' | 'draft'
/** A linked review is identified by a positive integer PR/MR number. */
export function isPositiveHostedReviewNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0
}
export type HostedReviewInfo = {
provider: HostedReviewProvider
number: number
title: string
state: HostedReviewState
url: string
status: CheckStatus
updatedAt: string
mergeable: PRMergeableState
reviewDecision?: PRReviewDecision | null
autoMergeEnabled?: boolean
autoMergeAllowed?: boolean | null
mergeQueueRequired?: boolean | null
mergeStateStatus?: string | null
headSha?: string
// Why: mirrors PRInfo.confirmedContainedHeadOid so merged-review staleness
// checks accept a worktree head confirmed to be part of the merged PR.
confirmedContainedHeadOid?: string
/** Target branch name for review-created worktree compare-base repair. */
baseRefName?: string
conflictSummary?: PRConflictSummary
}
export type HostedReviewForBranchArgs = {
repoPath: string
repoId?: string
branch: string
linkedGitHubPR?: number | null
fallbackGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
// The worktree's checked-out HEAD oid (GitHub merged-at-head visibility).
currentHeadOid?: string | null
}
export type HostedReviewSummary = {
number?: number
url: string
}
export type CreateHostedReviewInput = {
provider: HostedReviewProvider
base: string
head?: string
title: string
body?: string
draft?: boolean
worktreePath?: string
useTemplate?: boolean
}
export type CreateHostedReviewArgs = CreateHostedReviewInput & {
repoPath: string
repoId?: 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'
// Why: a stacked worktree's local-only parent base is unresolvable on the
// remote; blocked at create-time so the submit fails with actionable copy
// instead of the provider's opaque error.
| 'base_not_on_remote'
| null
export type HostedReviewCreationNextAction =
| 'commit'
| 'publish'
| 'push'
| 'sync'
| 'authenticate'
| 'open_existing_review'
| null
/**
* Records whether the eligibility result observed an authoritative existing-review
* lookup. `found` / `not_found` come only from an accepted provider lookup;
* `unavailable` marks a local-blocker fallback returned after a swallowed or
* skipped lookup, so it can never masquerade as authoritative no-review evidence.
*/
export type HostedReviewLookupOutcome = 'found' | 'not_found' | 'unavailable'
export type HostedReviewCreationEligibility = {
provider: HostedReviewProvider
review: HostedReviewSummary | null
canCreate: boolean
blockedReason: HostedReviewCreationBlockedReason
nextAction: HostedReviewCreationNextAction
reviewLookupOutcome: HostedReviewLookupOutcome
defaultBaseRef?: string | null
head?: string | null
title?: string | null
body?: string | null
}
export type HostedReviewCreationEligibilityArgs = {
repoPath: string
repoId?: string
worktreePath?: string
connectionId?: string | null
branch: string
base?: string | null
hasUncommittedChanges?: boolean
hasUpstream?: boolean
ahead?: number
behind?: number
linkedGitHubPR?: number | null
fallbackGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
}
export type HostedReviewIdentity = {
provider: HostedReviewProvider
host: string
owner: string
repo: string
number: number
}
export type HostedReviewUser = {
login: string | null
isBot?: boolean
}
export type HostedReviewDecision = 'approved' | 'changes_requested' | 'review_required' | null
export type HostedReviewThreadSummary = {
unresolvedCount: number | null
dataCompleteness?: 'full' | 'partial'
}
export type HostedReviewQueueSummary = {
identity: HostedReviewIdentity
title: string
url: string
state: HostedReviewState
author: HostedReviewUser | null
updatedAt: string
lastViewedAt?: number
mergeable: PRMergeableState
mergeStateStatus?: string | null
checksStatus: CheckStatus
reviewDecision?: HostedReviewDecision
threadSummary?: HostedReviewThreadSummary
requestedReviewerLogins?: string[] | null
draft?: boolean
}
export type HostedReviewQueueKey =
| 'mine'
| 'requested'
| 'agent'
| 'teammate'
| 'needs-response'
| 'ready-to-merge'
export type HostedReviewQueueState = 'mine' | 'requested' | 'agent' | 'teammate'
export type HostedReviewQueueClassification = {
state: HostedReviewQueueState
needsResponse: boolean
readyToMerge: boolean
requested: boolean
}