Files
orca/mobile/src/session/pr-actions-engine.ts
T
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00

323 lines
10 KiB
TypeScript

import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types'
import { githubRepoIdentityKey } from '../../../src/shared/github/repository-identity-key'
import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state'
import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
import type { GitHubPrRepoSlug } from './github-pr-rpc'
import type { PrActionMutations } from './pr-action-mutation-contract'
export type { PrActionMutations } from './pr-action-mutation-contract'
// Pure (React-free) engine for the PR mutation actions: owns optimistic fields,
// busy/error/blocked state, and the success/transient/permanent routing. The hook
// is a thin adapter that subscribes to `onChange` and exposes these methods. Kept
// React-free so the U6 action logic is unit-testable with injected fakes.
export type PrActionBusyKey =
| { kind: 'merge' }
| { kind: 'autoMerge' }
| { kind: 'state' }
| { kind: 'reviewer'; login: string }
| { kind: 'rerun' }
export function busyKeyEquals(a: PrActionBusyKey | null, b: PrActionBusyKey): boolean {
if (!a || a.kind !== b.kind) {
return false
}
if (a.kind === 'reviewer' && b.kind === 'reviewer') {
return a.login === b.login
}
return true
}
export type PrActionsEngineConfig = {
mutations: PrActionMutations
prNumber: number
headSha?: string | null
prRepo?: GitHubPrRepoSlug | null
refetch: () => void | Promise<void>
// Notifies subscribers (the hook) that observable state changed.
onChange: () => void
}
function prActionsIdentity(cfg: PrActionsEngineConfig): string {
const repo = cfg.prRepo ? githubRepoIdentityKey(cfg.prRepo) : ''
return `${cfg.prNumber}:${repo}`
}
export class PrActionsEngine {
private cfg: PrActionsEngineConfig
private identity: string
busy: PrActionBusyKey | null = null
error: string | null = null
// Permanent failure (R9) — surfaced persistently, no auto-retry.
blocked: string | null = null
private readonly autoMergeField: OptimisticField<boolean>
private readonly stateField: OptimisticField<PRState>
private readonly reviewerFields = new Map<string, OptimisticField<boolean>>()
constructor(cfg: PrActionsEngineConfig) {
this.cfg = cfg
this.identity = prActionsIdentity(cfg)
this.autoMergeField = createOptimisticField<boolean>(cfg.onChange)
this.stateField = createOptimisticField<PRState>(cfg.onChange)
}
// Allows the hook to refresh config (prNumber/headSha/prRepo/refetch) without
// recreating optimistic fields and losing in-flight guard state.
updateConfig(cfg: PrActionsEngineConfig): void {
const nextIdentity = prActionsIdentity(cfg)
this.cfg = cfg
if (nextIdentity !== this.identity) {
this.identity = nextIdentity
this.resetForIdentityChange()
}
}
isBusy(key: PrActionBusyKey): boolean {
return busyKeyEquals(this.busy, key)
}
clearError(): void {
if (this.error !== null) {
this.error = null
this.cfg.onChange()
}
}
clearBlocked(): void {
if (this.blocked !== null) {
this.blocked = null
this.cfg.onChange()
}
}
private reviewerField(login: string): OptimisticField<boolean> {
let f = this.reviewerFields.get(login)
if (!f) {
f = createOptimisticField<boolean>(this.cfg.onChange)
this.reviewerFields.set(login, f)
}
return f
}
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
// so we don't force a full PR panel re-render for free.
private setBusy(key: PrActionBusyKey | null): void {
if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) {
return
}
this.busy = key
this.cfg.onChange()
}
// Why: overlapping actions share `busy`; a late-resolving action must only clear
// it if it's still the one it set, so it can't wipe a newer action's busy state.
private clearBusyIfOwned(identity: string, key: PrActionBusyKey): void {
if (this.identity === identity && busyKeyEquals(this.busy, key)) {
this.setBusy(null)
}
}
private setError(message: string | null): void {
if (this.error === message) {
return
}
this.error = message
this.cfg.onChange()
}
private setBlocked(message: string): void {
this.blocked = message
this.cfg.onChange()
}
// Routes an outcome: success → refetch; transient → revert latest + non-blocking
// error; permanent (blocked) → no auto-retry, persistent blocked state (KTD7/R9).
private resetForIdentityChange(): void {
let changed = this.busy !== null || this.error !== null || this.blocked !== null
this.busy = null
this.error = null
this.blocked = null
changed = this.autoMergeField.reset() || changed
changed = this.stateField.reset() || changed
if (this.reviewerFields.size > 0) {
this.reviewerFields.clear()
changed = true
}
if (changed) {
this.cfg.onChange()
}
}
private async settle(
identity: string,
outcome: GitHubPrMutationOutcome,
handlers: { onSuccess: () => void; onRevert: () => void }
): Promise<void> {
if (this.identity !== identity) {
return
}
if (outcome.ok) {
handlers.onSuccess()
// Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox.
try {
await this.cfg.refetch()
} catch (err) {
if (this.identity === identity) {
this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.')
}
}
return
}
// Both failure classes clear optimism to authoritative; only the message
// routing differs (blocked is persistent and not retry-encouraged).
handlers.onRevert()
if (classifyPrSidebarFailure(outcome.error) === 'blocked') {
this.setBlocked(outcome.error)
return
}
this.setError(outcome.error)
}
async merge(method?: GitHubPRMergeMethod): Promise<void> {
const cfg = this.cfg
const identity = this.identity
this.setBusy({ kind: 'merge' })
this.setError(null)
try {
const outcome = await cfg.mutations.mergePR({
prNumber: cfg.prNumber,
method,
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
} catch (err) {
if (this.identity === identity) {
this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.')
}
} finally {
this.clearBusyIfOwned(identity, { kind: 'merge' })
}
}
async setAutoMerge(enabled: boolean, method?: GitHubPRMergeMethod): Promise<void> {
const cfg = this.cfg
const identity = this.identity
const seq = this.autoMergeField.begin(enabled)
this.setBusy({ kind: 'autoMerge' })
this.setError(null)
try {
const outcome = await cfg.mutations.setPRAutoMerge({
prNumber: cfg.prNumber,
enabled,
method,
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => this.autoMergeField.settleSuccess(seq),
onRevert: () => this.autoMergeField.settleFailure(seq)
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'autoMerge' })
}
}
async updateState(state: 'open' | 'closed'): Promise<void> {
const cfg = this.cfg
const identity = this.identity
const seq = this.stateField.begin(state === 'closed' ? 'closed' : 'open')
this.setBusy({ kind: 'state' })
this.setError(null)
try {
const outcome = await cfg.mutations.updatePRState({
prNumber: cfg.prNumber,
state,
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => this.stateField.settleSuccess(seq),
onRevert: () => this.stateField.settleFailure(seq)
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'state' })
}
}
async requestReviewer(login: string): Promise<void> {
const cfg = this.cfg
const identity = this.identity
const field = this.reviewerField(login)
const seq = field.begin(true)
this.setBusy({ kind: 'reviewer', login })
this.setError(null)
try {
const outcome = await cfg.mutations.requestReviewers({
prNumber: cfg.prNumber,
reviewers: [login],
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => field.settleSuccess(seq),
onRevert: () => field.settleFailure(seq)
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
}
}
async removeReviewer(login: string): Promise<void> {
const cfg = this.cfg
const identity = this.identity
const field = this.reviewerField(login)
const seq = field.begin(false)
this.setBusy({ kind: 'reviewer', login })
this.setError(null)
try {
const outcome = await cfg.mutations.removeReviewers({
prNumber: cfg.prNumber,
reviewers: [login],
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => field.settleSuccess(seq),
onRevert: () => field.settleFailure(seq)
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
}
}
async rerunFailingChecks(): Promise<void> {
const cfg = this.cfg
const identity = this.identity
this.setBusy({ kind: 'rerun' })
this.setError(null)
try {
const outcome = await cfg.mutations.rerunChecks({
prNumber: cfg.prNumber,
headSha: cfg.headSha,
failedOnly: true,
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
} finally {
this.clearBusyIfOwned(identity, { kind: 'rerun' })
}
}
resolveAutoMerge(authoritative: boolean): boolean {
return this.autoMergeField.resolve(authoritative)
}
resolveState(authoritative: PRState): PRState {
return this.stateField.resolve(authoritative)
}
resolveReviewerRequested(login: string, authoritative: boolean): boolean {
const f = this.reviewerFields.get(login)
return f ? f.resolve(authoritative) : authoritative
}
}