mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
#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.
206 lines
8.2 KiB
TypeScript
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
|
|
}
|