Files
orca/mobile/src/session/mobile-pr-sidebar-state.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00

206 lines
8.2 KiB
TypeScript

import type { PRCheckDetail } from '../../../src/shared/github/check-types'
import type { PRInfo } from '../../../src/shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../src/shared/github/work-item-types'
import type { GitHubPrReadOutcome, GitHubPrRepoSlug } from './github-pr-rpc'
import { resolveLinkedPrNumber } from './mobile-pr-sidebar-resolve'
// Pure state machine for the mobile PR sidebar. Kept free of React/native imports
// so the transitions are unit-testable under the node Vitest config (KTD5).
export type PrSidebarData = {
pr: PRInfo
details: GitHubWorkItemDetails | null
checks: PRCheckDetail[]
}
// `blocked` is a permanent failure (no GitHub account / permission denied) that the
// user cannot retry away; `error` is transient (network/timeout). Keeping them
// distinct (R9/KTD7) stops a permission denial from looping through revert+retry.
export type PrSidebarState =
| { kind: 'hidden' }
| { kind: 'loading' }
| { kind: 'ready'; data: PrSidebarData }
// The branch has no open PR — distinct from `hidden` so the opened sidebar can
// explain it (the dedicated icon is always available on a GitHub repo).
| { kind: 'none' }
| { kind: 'error'; message: string }
| { kind: 'blocked'; message: string }
// Why: host mutations/reads return permission and network failures in the same
// `{ ok:false, error:string }` shape; classify by message so a permanent failure
// routes to `blocked` instead of an endlessly-retryable `error`.
const PERMANENT_FAILURE_PATTERN =
/\b(not connected|no github|unauthenticated|not authenticated|gh auth|login|permission|forbidden|insufficient|401|403|404)\b/i
export function classifyPrSidebarFailure(message: string): 'blocked' | 'error' {
return PERMANENT_FAILURE_PATTERN.test(message) ? 'blocked' : 'error'
}
function failureState(
message: string
): { kind: 'error'; message: string } | { kind: 'blocked'; message: string } {
return classifyPrSidebarFailure(message) === 'blocked'
? { kind: 'blocked', message }
: { kind: 'error', message }
}
export type PrSidebarLoadDeps = {
fetchForBranch: (
worktreeId: string,
args: { branch: string; linkedGitHubPR?: number | null }
) => Promise<
GitHubPrReadOutcome<import('../../../src/shared/hosted-review').HostedReviewInfo | null>
>
// The worktree's persisted linkedPR (fallback resolver for closed/merged PRs).
// Fetched in parallel with forBranch to keep it off the critical path.
fetchWorktreeLinkedPR: (worktreeId: string) => Promise<number | null>
fetchPRForBranch: (
worktreeId: string,
args: { branch: string; linkedPRNumber?: number | null }
) => Promise<GitHubPrReadOutcome<PRInfo | null>>
fetchWorkItemDetails: (
worktreeId: string,
args: { prNumber: number }
) => Promise<GitHubPrReadOutcome<GitHubWorkItemDetails | null>>
fetchPRChecks: (
worktreeId: string,
args: { prNumber: number; headSha?: string | null; prRepo?: GitHubPrRepoSlug | null }
) => Promise<GitHubPrReadOutcome<PRCheckDetail[]>>
}
// Phase 1: load the PR + checks fast and show the sidebar. The heavy comments/body
// payload (workItemDetails) is deferred to loadPrSidebarDetails so it never blocks the
// actionable PR UI — `data.details` starts null and is filled in by the second phase.
// forBranch + the worktree linkedPR read run in parallel (independent), then combine
// via resolveLinkedPrNumber so a closed/merged linked PR still resolves (KTD4).
export async function loadPrSidebarData(
deps: PrSidebarLoadDeps,
args: {
worktreeId: string
branch: string
headSha?: string | null
prRepo?: GitHubPrRepoSlug | null
}
): Promise<PrSidebarState> {
try {
const [hintOutcome, linkedPR] = await Promise.all([
deps.fetchForBranch(args.worktreeId, { branch: args.branch }),
deps.fetchWorktreeLinkedPR(args.worktreeId)
])
const branchHint =
hintOutcome.ok && hintOutcome.result?.provider === 'github' ? hintOutcome.result.number : null
const linkedPRNumber = resolveLinkedPrNumber(branchHint, linkedPR)
const prOutcome = await deps.fetchPRForBranch(args.worktreeId, {
branch: args.branch,
linkedPRNumber
})
if (!prOutcome.ok) {
return failureState(prOutcome.error)
}
if (!prOutcome.result) {
// GitHub repo, but this branch has no open/linked PR — surfaced as an empty state.
return { kind: 'none' }
}
const pr = prOutcome.result
const checksOutcome = await deps.fetchPRChecks(args.worktreeId, {
prNumber: pr.number,
// Why: mobile create can commit before opening the review; the fetched PR
// head is fresher than the route's cached git.status head.
headSha: pr.headSha ?? args.headSha ?? null,
// Prefer the fetched PR's own repo identity so fork PRs key their cached
// checks correctly; fall back to an explicit override then null.
prRepo: pr.prRepo ?? args.prRepo ?? null
})
if (!checksOutcome.ok) {
return failureState(checksOutcome.error)
}
// details: null = comments still loading (phase 2). The header/reviewers degrade to
// the PRInfo fields until it arrives.
return { kind: 'ready', data: { pr, details: null, checks: checksOutcome.result } }
} catch (err) {
// Why: a dep that rejects (instead of returning `{ ok:false }`) must still
// resolve to an error state, not escape as an unhandled rejection.
return failureState(err instanceof Error ? err.message : 'Unable to load pull request')
}
}
// Phase 2: fetch the work-item details (body + comments + participants). Non-fatal —
// a failure leaves the PR shown with an empty comments section rather than erroring out.
export async function loadPrSidebarDetails(
deps: PrSidebarLoadDeps,
worktreeId: string,
prNumber: number
): Promise<GitHubWorkItemDetails | null> {
try {
const outcome = await deps.fetchWorkItemDetails(worktreeId, { prNumber })
return outcome.ok ? outcome.result : null
} catch {
// Why: phase 2 is non-fatal — a rejection leaves the PR shown without comments
// rather than escaping as an unhandled rejection.
return null
}
}
// UI treats details===null as "still loading". After phase 2 finishes (success or
// non-fatal failure), never leave null — use prior comments if any, else empty body.
export function resolvePrSidebarDetailsAfterPhase2(args: {
fetched: GitHubWorkItemDetails | null
prior: GitHubWorkItemDetails | null
pr: PRInfo
}): GitHubWorkItemDetails {
if (args.fetched != null) {
return args.fetched
}
if (args.prior != null) {
return args.prior
}
return emptyPrSidebarDetails(args.pr)
}
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
// The synthetic id is the only id shape we mint — real GitHub node ids never match.
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
return {
item: {
id: `pr-${pr.number}`,
type: 'pr',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
labels: [],
updatedAt: pr.updatedAt,
author: null
},
body: '',
comments: []
}
}
// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews).
export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean {
return details.item.id === `pr-${details.item.number}`
}
// Phase-2 still needs a real payload: null (in flight / not started) or a
// synthetic placeholder from a failed fetch. Real details (including empty body)
// do not qualify — ensure/retry must not re-pull those.
export function prSidebarDetailsNeedFetch(
details: GitHubWorkItemDetails | null | undefined
): boolean {
return details == null || isPrSidebarDetailsPlaceholder(details)
}
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
// no visible/in-flight sidebar work (hidden or terminal error states).
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
return kind === 'ready' || kind === 'none' || kind === 'loading'
}
// Stale-response guard (KTD6): a load tagged with an older sequence must not
// overwrite a newer one. The hook bumps a monotonic counter per load.
export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean {
return resultSeq === latestSeq
}