Files
orca/src/renderer/src/store/slices/github.ts
T
bf2a6c1040 Don't enqueue local PR refresh for remote-host repos (fix renderer OOM crash) (#6094)
* Don't enqueue local PR refresh for remote-host repos (fix renderer OOM)

The renderer enqueues GitHub PR refreshes for worktrees. Remote/SSH/runtime
worktrees are meant to refresh through the runtime route (getRuntimeRepoTarget),
but when that route is unavailable — host not the active environment, or
disconnected — the call falls through to the local `gh:enqueuePRRefresh` IPC.
The local handler only resolves repos registered in the local store, so it
rejects every such call with "Access denied: unknown repository path".

With a remote worktree active (e.g. a runtime "Project server" workspace), this
fires on a loop (worktree activation + SWR polling), flooding the renderer with
failed invokes and unhandled rejections. Observed hundreds of these per session;
the renderer JS heap climbs to the V8 ceiling (~3.5GB) and V8 aborts the process
(crash-reports.json: renderer crash, exitCode 5, usedHeapMB == heapLimitMB).

Add isLocalHostPRRefreshCandidate and gate all four enqueue sites on it, so the
local handler only ever receives local-host candidates. Remote candidates with
no available runtime route are skipped (their PR status refreshes once the host
is active/reconnected) instead of spamming a handler that can't serve them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover local executionHostId in PR-refresh host guard

Address CodeRabbit nitpick on #6094: add a positive case where a local repo
carries an explicit executionHostId === LOCAL_EXECUTION_HOST_ID, completing
predicate coverage for isLocalHostPRRefreshCandidate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: route PR refreshes by repo host

Co-authored-by: Orca <help@stably.ai>

* fix: skip disconnected SSH PR refreshes

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-06-22 20:47:18 -07:00

4077 lines
139 KiB
TypeScript

/* eslint-disable max-lines -- Why: the GitHub slice co-locates all cache + fetch logic for
PR, issue, checks, and comments data so the dedup and invalidation patterns stay consistent. */
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type {
ClassifiedError,
GitHubOwnerRepo,
GitHubPRRefreshAlias,
IssueSourcePreference,
PRInfo,
GitHubPRRefreshCandidate,
GitHubPRRefreshEvent,
GitHubPRRefreshReason,
GitHubCommentResult,
IssueInfo,
PRCheckDetail,
PRCheckRunDetails,
PRComment,
Repo,
Worktree,
GitHubWorkItem,
ListWorkItemsResult,
GlobalSettings
} from '../../../../shared/types'
import type {
GetProjectViewTableArgs,
GetProjectViewTableResult,
GitHubProjectFieldMutationValue,
GitHubProjectMutationResult,
GitHubProjectRow,
GitHubProjectTable,
GitHubProjectViewError
} from '../../../../shared/github-project-types'
import {
isGitHubWorkItemsSshRemoteRequiredError,
sortWorkItemsByUpdatedAt,
PER_REPO_FETCH_LIMIT
} from '../../../../shared/work-items'
import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { settingsForProjectRowOwner } from './github-project-row-owner'
import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility'
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key'
import { isGitHubWorkItemsQueryTooLarge } from './github-work-items-query-bounds'
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
import { translate } from '@/i18n/i18n'
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import {
getTaskSourceCacheScope,
getTaskSourceRuntimeSettings,
type TaskSourceContext
} from '../../../../shared/task-source-context'
// ─── ProjectV2 cache types ────────────────────────────────────────────
// Why: declared separately from CacheEntry<T> (not a generified E parameter)
// because project-view has a single GraphQL source — no issue/PR-source
// fallback — and the error union is distinct. Shared structural shape only.
export type ProjectViewCacheEntry<T> = {
data: T | null
fetchedAt: number
error?: GitHubProjectViewError
}
export type ProjectRowContentUpdate = {
title?: string
body?: string
addLabels?: string[]
removeLabels?: string[]
addAssignees?: string[]
removeAssignees?: string[]
}
export type GitHubPatchWorkItemOptions = {
sourceContext?: TaskSourceContext | null
}
/** Optimistic, IPC-free patch shape for `projectViewCache` rows.
* Why: the dialog already issues mutations via slug-addressed IPCs and only
* needs to keep the Project table view in sync optimistically. Replacing
* `addLabels`/`removeLabels` deltas with full `labels`/`assignees` arrays
* matches what the dialog's local state already tracks (`localLabels`,
* `localAssignees`) and avoids redundant set-merge logic at the call site. */
export type ProjectRowContentPatch = {
title?: string
body?: string
/** Why: accept the renderer's lowercase work-item state vocabulary
* ('open' | 'closed' | 'merged' | 'draft') and translate to GitHub's
* UPPERCASE row.content.state when applying. The reducer only writes
* what callers send; merged/draft are passed through for completeness
* even though the dialog edits only flip open↔closed today. */
state?: 'open' | 'closed' | 'merged' | 'draft'
labels?: string[]
assignees?: string[]
}
// Why: queryOverride participates in the cache key so an overridden search
// does not clobber the default-view cache entry, and vice versa. `undefined`
// means "use the view's stored filter" — the unfiltered cache entry. An
// empty string is a *distinct* override meaning "no filter", which produces
// different rows when the view's stored filter is non-empty, so it gets its
// own cache key.
function queryOverrideKeyPart(queryOverride: string | undefined): string {
if (queryOverride === undefined) {
return ''
}
return `:q=${queryOverride}`
}
function getRuntimeRepoTarget(
state: AppState,
repoPath: string,
settings: AppState['settings'] = state.settings
): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return null
}
const repo = state.repos.find((candidate) => candidate.path === repoPath)
return repo ? { target, repo } : null
}
function getPRRefreshOwnerRuntimeEnvironmentId(
candidate: Pick<GitHubPRRefreshCandidate, 'cacheKey' | 'executionHostId'>
): string | null {
const parsed = parseExecutionHostId(candidate.executionHostId)
if (parsed?.kind === 'runtime') {
return parsed.environmentId
}
const cacheScope = candidate.cacheKey.split('::', 1)[0]
const cacheScopeHost = parseExecutionHostId(cacheScope)
return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null
}
function getPRRefreshRuntimeRepoTarget(
state: AppState,
candidate: GitHubPRRefreshCandidate
): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null {
const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate)
if (!ownerRuntimeEnvironmentId) {
return null
}
// Why: PR refreshes must follow the repo owner host, not the Active Server
// dropdown. A runtime-owned worktree can be visible while Local desktop is focused.
return getRuntimeRepoTarget(
state,
candidate.repoPath,
state.settings
? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId }
: ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings'])
)
}
function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean {
// Why: the local PR coordinator owns local git and SSH bridge refreshes, but
// runtime-owned repos and disconnected SSH repos must not hit the IPC crash path.
if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) {
return false
}
return !candidate.connectionId || candidate.connectionState === 'connected'
}
type GitHubWorkItemRequestContext = {
repoId: string
repoPath: string
target: GitHubWorkItemRequestTarget
}
type GitHubWorkItemRequestTarget =
| { kind: 'environment'; environmentId: string; runtimeRepoId: string }
| { kind: 'local' }
type GitHubWorkItemsListArgs = {
limit: number
query?: string
before?: string
noCache?: true
}
function settingsForGitHubRepoOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
return settings
}
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
if (parsed?.kind === 'runtime') {
return settings
? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId }
: ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings'])
}
// Why: local and SSH-owned GitHub lookups are served by the desktop client;
// host focus must not redirect them to the currently selected runtime.
return settings
? { ...settings, activeRuntimeEnvironmentId: null }
: ({ activeRuntimeEnvironmentId: null } as AppState['settings'])
}
function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string {
const explicitHostId = normalizeExecutionHostId(alias.executionHostId)
if (explicitHostId) {
return explicitHostId
}
const scope = alias.cacheKey.split('::', 1)[0]
return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID
}
function findRepoForGitHubOwner(
state: Partial<Pick<AppState, 'repos'>>,
repoId: string | undefined,
repoPath: string
): Repo | undefined {
return (state.repos ?? []).find((candidate) =>
repoId ? candidate.id === repoId || candidate.path === repoPath : candidate.path === repoPath
)
}
function getGitHubRepoOwnerHostId(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): string {
if (repo?.executionHostId || repo?.connectionId) {
return getRepoExecutionHostId(repo)
}
return getSettingsFocusedExecutionHostId(settings)
}
function getWorkItemsCacheKeyForOwner(
state: Partial<Pick<AppState, 'repos' | 'settings'>>,
repoId: string,
limit: number,
query: string,
repoPath?: string
): string {
const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '')
return workItemsCacheKey(
repoId,
limit,
query,
repo ? getGitHubRepoOwnerHostId(state.settings ?? null, repo) : undefined
)
}
function getGitHubWorkItemSourceHostId(
state: AppState,
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
sourceContext?: TaskSourceContext | null
): ExecutionHostId | undefined {
if (sourceContext?.provider === 'github') {
return sourceContext.hostId
}
return repo
? (normalizeExecutionHostId(getGitHubRepoOwnerHostId(state.settings, repo)) ?? undefined)
: undefined
}
function getGitHubWorkItemSourceCacheScope(
state: AppState,
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
sourceContext?: TaskSourceContext | null
): string | undefined {
if (sourceContext?.provider === 'github') {
return getTaskSourceCacheScope(sourceContext)
}
return getGitHubWorkItemSourceHostId(state, repo, sourceContext)
}
function getGitHubWorkItemSourceSettings(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
sourceContext?: TaskSourceContext | null
): AppState['settings'] {
if (sourceContext?.provider === 'github') {
return {
...settings,
...getTaskSourceRuntimeSettings(sourceContext)
} as AppState['settings']
}
return settingsForGitHubRepoOwner(settings, repo)
}
function getGitHubWorkItemRequestContext(
state: AppState,
settings: AppState['settings'],
repoId: string,
repoPath: string,
sourceContext?: TaskSourceContext | null
): GitHubWorkItemRequestContext {
if (sourceContext?.provider === 'github') {
const parsedHost = parseExecutionHostId(sourceContext.hostId)
if (parsedHost?.kind === 'runtime') {
return {
repoId,
repoPath,
target: {
kind: 'environment',
environmentId: parsedHost.environmentId,
runtimeRepoId: sourceContext.repoId ?? repoId
}
}
}
}
const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings)
return {
repoId,
repoPath,
target: runtimeRepo
? {
kind: 'environment',
environmentId: runtimeRepo.target.environmentId,
runtimeRepoId: runtimeRepo.repo.id
}
: { kind: 'local' }
}
}
function listGitHubWorkItemsForRepo(
context: GitHubWorkItemRequestContext,
args: GitHubWorkItemsListArgs
): Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>> {
if (context.target.kind === 'environment') {
return callRuntimeRpc<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>>(
{ kind: 'environment', environmentId: context.target.environmentId },
'github.listWorkItems',
{
repo: context.target.runtimeRepoId,
...args
},
{ timeoutMs: 30_000 }
)
}
return window.api.gh.listWorkItems({
repoPath: context.repoPath,
repoId: context.repoId,
...args
})
}
function countGitHubWorkItemsForRepo(
context: GitHubWorkItemRequestContext,
args: { query?: string }
): Promise<number> {
if (context.target.kind === 'environment') {
return callRuntimeRpc<number>(
{ kind: 'environment', environmentId: context.target.environmentId },
'github.countWorkItems',
{
repo: context.target.runtimeRepoId,
...args
},
{ timeoutMs: 30_000 }
)
}
return window.api.gh.countWorkItems({
repoPath: context.repoPath,
repoId: context.repoId,
...args
})
}
export function projectViewCacheKey(
ownerType: GetProjectViewTableArgs['ownerType'],
owner: string,
projectNumber: number,
resolvedViewId: string,
queryOverride?: string,
sourceScope = 'local'
): string {
return `github-project:${sourceScope}:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}`
}
function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string {
// Why: callers without `viewId` can't compute the resolved cache key up
// front. Use the input-arg signature for inflight dedup; the resolved
// cache key is only known after the main-process IPC returns.
const selector = args.viewId
? `id:${args.viewId}`
: args.viewNumber !== undefined
? `num:${args.viewNumber}`
: args.viewName
? `name:${args.viewName}`
: 'default'
return `${sourceScope}:${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}`
}
function projectViewSourceScope(settings: AppState['settings']): string {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
}
function settingsForProjectViewCacheKey(
settings: AppState['settings'],
cacheKey: string
): Pick<NonNullable<AppState['settings']>, 'activeRuntimeEnvironmentId'> {
const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey)
if (runtimeMatch) {
return { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] }
}
return { ...settings, activeRuntimeEnvironmentId: null }
}
// Why: module-scope inflight map — must mirror `inflightWorkItemsRequests`
// (dedup + force-refresh semantics). Reuses the work-item concurrency gate:
// the gate exists to bound `gh` subprocess pressure at the renderer boundary,
// and project-view fetches pressure the same subprocess budget. Two separate
// gates would let concurrent Project + work-item fetches blow past the cap.
const inflightProjectViewRequests = new Map<
string,
{ promise: Promise<GetProjectViewTableResult>; force: boolean }
>()
// Why: derive an optimistic GitHubProjectFieldValue from a mutation value so
// the patched row re-renders immediately. Single-select and iteration lookups
// consult the field config on the cached table; the result is best-effort and
// is overwritten by the authoritative payload on next refresh.
function optimisticFieldValueFromMutation(
table: GitHubProjectTable,
fieldId: string,
value: GitHubProjectFieldMutationValue
): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null {
const field = table.selectedView.fields.find((f) => f.id === fieldId)
switch (value.kind) {
case 'single-select': {
if (field?.kind === 'single-select') {
const option = field.options.find((o) => o.id === value.optionId)
if (option) {
return {
kind: 'single-select',
fieldId,
optionId: option.id,
name: option.name,
color: option.color
}
}
}
return {
kind: 'single-select',
fieldId,
optionId: value.optionId,
name: '',
color: ''
}
}
case 'iteration': {
if (field?.kind === 'iteration') {
const iteration = field.iterations.find((i) => i.id === value.iterationId)
if (iteration) {
return {
kind: 'iteration',
fieldId,
iterationId: iteration.id,
title: iteration.title,
startDate: iteration.startDate,
duration: iteration.duration
}
}
}
return {
kind: 'iteration',
fieldId,
iterationId: value.iterationId,
title: '',
startDate: '',
duration: 0
}
}
case 'text':
return { kind: 'text', fieldId, text: value.text }
case 'number':
return { kind: 'number', fieldId, number: value.number }
case 'date':
return { kind: 'date', fieldId, date: value.date }
}
return null
}
function applyRowPatch(
set: (fn: (s: AppState) => Partial<AppState>) => void,
cacheKey: string,
rowId: string,
nextRow: GitHubProjectRow
): void {
set((s) => {
const entry = s.projectViewCache[cacheKey]
if (!entry?.data) {
return {}
}
const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {}
}
const rows = [...entry.data.rows]
rows[rowIndex] = nextRow
return {
projectViewCache: {
...s.projectViewCache,
[cacheKey]: {
...entry,
data: { ...entry.data, rows }
}
}
}
})
}
function rollbackRowIfPresent(
set: (fn: (s: AppState) => Partial<AppState>) => void,
get: () => AppState,
cacheKey: string,
rowId: string,
previousRow: GitHubProjectRow
): void {
// Why: the cache entry may have moved (rapid project switch) or the row may
// no longer exist by the time the mutation response returns. Skip rollback
// in that case — resurrecting stale data into a newly selected project would
// show the wrong row.
const entry = get().projectViewCache[cacheKey]
if (!entry?.data) {
return
}
const stillPresent = entry.data.rows.some((r) => r.id === rowId)
if (!stillPresent) {
return
}
applyRowPatch(set, cacheKey, rowId, previousRow)
}
function parseSlugAndNumber(
row: GitHubProjectRow
): { owner: string; repo: string; number: number } | null {
if (!row.content.repository || row.content.number == null) {
return null
}
const parts = row.content.repository.split('/')
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null
}
return { owner: parts[0], repo: parts[1], number: row.content.number }
}
export type WorkItemsCacheSources = {
issues: GitHubOwnerRepo | null
prs: GitHubOwnerRepo | null
/** Raw origin remote (if any). Required-nullable so selector code can
* distinguish the raw candidate from the effective PR source. */
originCandidate: GitHubOwnerRepo | null
/** Raw upstream remote (if any) — present so the selector can render
* independently of the currently-effective preference. Required-nullable
* (matches siblings `issues`/`prs`) so consumers only branch on `null`
* vs value, not a three-state (undefined | null | value). */
upstreamCandidate: GitHubOwnerRepo | null
}
// Why: the indicator and retry banner both need the resolved owner/repo for
// the failing side. Stamping the slug onto the error keeps the banner copy
// correct even when the error outlives the cache entry's `sources` field
// (e.g. on partial-success merges where `data` is retained from a later read).
export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo }
export type CacheEntry<T> = {
data: T | null
fetchedAt: number
headSha?: string
/**
* Resolved issue/PR owner/repo slugs for this entry. Set only on entries
* populated by `fetchWorkItems` — PR and issue single-item caches don't
* carry sources since the indicator surfaces derive from list reads.
*/
sources?: WorkItemsCacheSources
/**
* Per-side classified error. Present when one (or both) of the underlying
* gh list calls failed. Partial-success reads keep `data` from the
* successful side and record the failing side here so the banner + list
* render together.
*/
error?: WorkItemsCacheError
/**
* True when the resolver fell back to origin because the user's preferred
* `'upstream'` remote is no longer configured for this repo. Consumers
* surface a one-time toast per session/repo; TaskPage tracks the
* already-toasted set so repeated refreshes don't re-toast.
* Typed as `?: true` (not `?: boolean`) to encode the invariant "present
* iff fell-back" — an explicit `false` write would be a bug.
*/
issueSourceFellBack?: true
}
type FetchOptions = {
force?: boolean
noCache?: boolean
sourceContext?: TaskSourceContext | null
}
type RepoScopedFetchOptions = FetchOptions & {
repoId?: string
}
type PRRefreshState = {
status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error'
reason: GitHubPRRefreshReason
updatedAt: number
pausedUntil?: number
message?: string
}
function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolean {
return reason === 'manual' || reason === 'active' || reason === 'post-push'
}
const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed)
const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently
const EMPTY_CHECKS_CACHE_TTL = 10_000
// Why: the NewWorkspace page's work-item list is a browse surface, not a
// source of truth, so 60s staleness is fine — stale data renders instantly
// while a background refresh keeps it current.
const WORK_ITEMS_CACHE_TTL = 60_000
// Why: match repos.ts so error toasts surfaced from this slice share the same
// long-lived duration — the user needs time to read + act on persist failures
// rather than having the toast vanish behind default short-lived timings.
const ERROR_TOAST_DURATION = 60_000
const inflightPRRequests = new Map<
string,
{ promise: Promise<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string }
>()
const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
type InflightChecks = {
promise: Promise<PRCheckDetail[]>
force: boolean
noCache: boolean
}
const inflightChecksRequests = new Map<string, InflightChecks>()
const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
type InflightWorkItems = {
promise: Promise<GitHubWorkItem[]>
force: boolean
noCache: boolean
}
const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
const prRequestGenerations = new Map<string, number>()
const prRefreshStartedHostedReviewEntries = new Map<
string,
AppState['hostedReviewCache'][string] | undefined
>()
const PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX = 128
/** @internal - exposed for leak-regression tests only */
export function _getGitHubPRRequestGenerationCountForTest(): number {
return prRequestGenerations.size
}
/** @internal - exposed for leak-regression tests only */
export function _getGitHubPRRefreshStartedEntryCountForTest(): number {
return prRefreshStartedHostedReviewEntries.size
}
/** @internal - exposed for leak-regression tests only */
export function _clearGitHubPRRefreshStartedEntriesForTest(): void {
prRefreshStartedHostedReviewEntries.clear()
}
// Why: cap in-flight cross-repo fan-out and hover-prefetches at the renderer
// boundary — the main-side gate is behind the IPC queue, so it can't see a
// stampede until the calls are already mid-flight. 8 balances responsiveness
// against gh rate-limit pressure.
const WORK_ITEM_FETCH_CONCURRENCY = 8
let workItemFetchInFlight = 0
const workItemFetchWaiters: (() => void)[] = []
async function acquireWorkItemSlot(): Promise<void> {
if (workItemFetchInFlight < WORK_ITEM_FETCH_CONCURRENCY) {
workItemFetchInFlight += 1
return
}
await new Promise<void>((resolve) => workItemFetchWaiters.push(resolve))
// Why: resolver has already claimed the slot on our behalf, so we don't
// re-increment here. Pairing convention: acquireWorkItemSlot + releaseWorkItemSlot.
}
function releaseWorkItemSlot(): void {
const next = workItemFetchWaiters.shift()
if (next) {
// Hand the slot off directly — net count unchanged — so we can't race a
// third caller into the cap between decrement and resolve.
next()
return
}
workItemFetchInFlight -= 1
}
export function workItemsCacheKey(
repoId: string,
limit: number,
query: string,
executionHostId?: string | null
): string {
const scope = executionHostId?.trim() ?? ''
const hostId = normalizeExecutionHostId(scope)
const owner = `${repoId}::${limit}::${query}`
if (hostId) {
return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner
}
return scope ? `${scope}::${owner}` : owner
}
function workItemsInflightRequestKey(
cacheKey: string,
target: GitHubWorkItemRequestTarget
): string {
const targetPart =
target.kind === 'environment' ? `env:${target.environmentId}:${target.runtimeRepoId}` : 'local'
return `${cacheKey}::${targetPart}`
}
export function issueCacheKey(
repoPath: string,
repoId: string | undefined,
issueNumber: number | string,
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
connectionId?: string | null,
executionHostId?: string | null
): string {
return getGitHubRepoCacheKey(
repoPath,
repoId,
String(issueNumber),
settings,
connectionId,
executionHostId
)
}
function runtimeScopedRepoCacheKey(
repoPath: string,
repoId: string | undefined,
suffix: string,
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null
): string {
return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId, executionHostId)
}
function sourceScopedRepoCacheKey(
repoPath: string,
repoId: string | undefined,
suffix: string,
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null,
sourceContext?: TaskSourceContext | null
): string {
if (sourceContext?.provider === 'github') {
return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}`
}
return runtimeScopedRepoCacheKey(
repoPath,
repoId,
suffix,
settings,
connectionId,
executionHostId
)
}
function prCacheKey(
repoPath: string,
repoId: string | undefined,
branch: string,
settings?: AppState['settings'],
connectionId?: string | null,
executionHostId?: string | null
): string {
return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId)
}
function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] {
const prefixes = [`${repoId}::`]
if (repoPath && repoPath !== repoId) {
prefixes.push(`${repoPath}::`)
}
return prefixes
}
function matchesRepoCacheKey(key: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => key.startsWith(prefix))
}
function clearInflightWorkItemsForRepo(repoId: string, repoPath?: string): void {
const prefixes = repoCacheKeyPrefixes(repoId, repoPath)
for (const key of Array.from(inflightWorkItemsRequests.keys())) {
if (matchesRepoCacheKey(key, prefixes)) {
inflightWorkItemsRequests.delete(key)
}
}
}
function evictRepoCacheEntries<T>(
cache: Record<string, CacheEntry<T>>,
prefixes: readonly string[]
): { cache: Record<string, CacheEntry<T>>; evicted: boolean } {
let next: Record<string, CacheEntry<T>> | null = null
for (const key of Object.keys(cache)) {
if (!matchesRepoCacheKey(key, prefixes)) {
continue
}
if (!next) {
next = { ...cache }
}
delete next[key]
}
return next ? { cache: next, evicted: true } : { cache, evicted: false }
}
function normalizedRepoIdentity(repo: GitHubOwnerRepo): string {
return `${repo.owner.toLowerCase()}/${repo.repo.toLowerCase()}`
}
function normalizedHeadSha(headSha?: string): string | null {
const trimmed = headSha?.trim()
return trimmed ? trimmed.toLowerCase() : null
}
export function prChecksCacheSuffix(
prNumber: number,
prRepo?: GitHubOwnerRepo | null,
headSha?: string
): string {
const headSuffix = normalizedHeadSha(headSha)
const base = prRepo
? `pr-checks::${normalizedRepoIdentity(prRepo)}::${prNumber}`
: `pr-checks::${prNumber}`
return headSuffix ? `${base}::head::${headSuffix}` : base
}
export function prCommentsCacheSuffix(prNumber: number, prRepo?: GitHubOwnerRepo | null): string {
if (!prRepo) {
return `pr-comments::${prNumber}`
}
return `pr-comments::${normalizedRepoIdentity(prRepo)}::${prNumber}`
}
function commentTimestamp(comment: PRComment): number {
const timestamp = new Date(comment.createdAt).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
export function mergePRCommentIntoList(
comments: readonly PRComment[] | null | undefined,
incoming: PRComment
): PRComment[] {
const byId = new Map<number, PRComment>()
for (const comment of comments ?? []) {
byId.set(comment.id, comment)
}
const previous = byId.get(incoming.id)
byId.set(incoming.id, {
...previous,
...incoming,
threadId: incoming.threadId ?? previous?.threadId,
path: incoming.path ?? previous?.path,
line: incoming.line ?? previous?.line,
startLine: incoming.startLine ?? previous?.startLine,
isResolved: incoming.isResolved ?? previous?.isResolved,
isOutdated: incoming.isOutdated ?? previous?.isOutdated
})
return Array.from(byId.values()).sort((a, b) => commentTimestamp(a) - commentTimestamp(b))
}
function hasUsableCommentPayload(result: GitHubCommentResult): result is {
ok: true
comment: PRComment
} {
return (
result.ok &&
typeof result.comment?.id === 'number' &&
Number.isSafeInteger(result.comment.id) &&
result.comment.id > 0 &&
typeof result.comment.body === 'string' &&
typeof result.comment.createdAt === 'string'
)
}
// Why: 500 entries is generous enough that active developers will never hit it
// during normal use, but prevents the cache from growing without bound across
// many repos and branches over a long-running session.
const MAX_CACHE_ENTRIES = 500
type GitHubPRFallbackSource = NonNullable<GitHubPRRefreshAlias['fallbackPRSource']>
function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < ttl
}
function getPRChecksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number {
return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL
}
function findWorktreeById(state: AppState, worktreeId: string): Worktree | null {
for (const worktrees of Object.values(state.worktreesByRepo)) {
const worktree = worktrees.find((w) => w.id === worktreeId)
if (worktree) {
return worktree
}
}
return null
}
function isStaleExactLinkedPRLookup(
state: AppState,
worktreeId: string | undefined,
linkedPRNumber: number | null | undefined
): boolean {
if (!worktreeId || linkedPRNumber == null) {
return false
}
return findWorktreeById(state, worktreeId)?.linkedPR !== linkedPRNumber
}
function buildPRRefreshCandidate(
state: AppState,
worktree: Worktree,
repoPath?: string
): GitHubPRRefreshCandidate | null {
const repo = state.repos.find((r) => r.id === worktree.repoId)
if (!repo) {
return null
}
if (isMacAppDataPath(repoPath ?? repo.path)) {
return null
}
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const cacheKey = prCacheKey(
repoPath ?? repo.path,
repo.id,
branch,
settingsForGitHubRepoOwner(state.settings, repo),
repo.connectionId,
repo.executionHostId
)
const cachedPR = state.prCache[cacheKey]?.data ?? null
const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber(
state,
repoPath ?? repo.path,
repo.id,
branch,
repo.connectionId,
repo.executionHostId
)
const cachedFallbackPRNumber = cachedPR?.number ?? null
const fallbackPRNumber =
worktree.linkedPR == null ? (cachedFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null
const fallbackPRSource: GitHubPRFallbackSource | null =
worktree.linkedPR != null || fallbackPRNumber == null
? null
: cachedFallbackPRNumber != null
? 'pr-cache'
: 'hosted-review'
const sshStatus = repo.connectionId
? state.sshConnectionStates.get(repo.connectionId)?.status
: null
return {
repoId: repo.id,
repoPath: repoPath ?? repo.path,
repoKind: repo.kind ?? 'git',
branch,
cacheKey,
worktreeId: worktree.id,
// Why: persisted linked PR metadata is exact, while PR cache numbers are
// only fallback hints after branch lookup misses.
linkedPRNumber: worktree.linkedPR ?? null,
fallbackPRNumber,
fallbackPRSource,
isBare: worktree.isBare,
isArchived: worktree.isArchived,
connectionId: repo.connectionId ?? null,
executionHostId: repo.executionHostId ?? null,
connectionState: repo.connectionId
? sshStatus === 'connected'
? 'connected'
: 'disconnected'
: 'unknown',
cachedFetchedAt: state.prCache[cacheKey]?.fetchedAt ?? null,
cachedHasPR: cachedPR ? true : state.prCache[cacheKey] ? false : null,
cachedPRState: cachedPR?.state ?? null,
cachedChecksStatus: cachedPR?.checksStatus ?? null,
cachedMergeable: cachedPR?.mergeable ?? null,
cachedMergeStateStatus: cachedPR?.mergeStateStatus ?? null
}
}
function githubHostedReviewFallbackPRNumber(
state: AppState,
repoPath: string,
repoId: string | undefined,
branch: string,
connectionId?: string | null,
executionHostId?: string | null
): number | null {
const hostedReviewCacheKey = getHostedReviewCacheKey(
repoPath,
branch,
state.settings,
repoId,
connectionId,
executionHostId
)
const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data
return hostedReview?.provider === 'github' ? hostedReview.number : null
}
function shouldClearHostedReviewForNoGitHubPR(
entry: AppState['hostedReviewCache'][string] | undefined
): boolean {
// Why: a GitHub-only miss should not create or refresh provider-neutral
// branch misses that suppress discovery for GitLab/other hosted reviews.
if (!entry) {
return false
}
if (entry.data?.provider === 'github') {
return true
}
return entry.data === null && isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey)
}
function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean {
return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false
}
function prLookupHintKey(linkedPRNumber: number | null, fallbackPRNumber: number | null): string {
if (linkedPRNumber !== null) {
return `linked:${linkedPRNumber}`
}
return fallbackPRNumber !== null ? `fallback:${fallbackPRNumber}` : ''
}
function linkedReviewHintKeyForNoGitHubPR(
entry: AppState['hostedReviewCache'][string] | undefined
): string | undefined {
if (entry?.data?.provider === 'github') {
return isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey)
? entry.linkedReviewHintKey
: linkedReviewHintKey({ linkedGitHubPR: entry.data.number })
}
return entry?.linkedReviewHintKey
}
function hasNewerHostedReviewCacheEntry(
cache: AppState['hostedReviewCache'],
cacheKey: string,
requestStartedAt: number,
requestStartedEntry: AppState['hostedReviewCache'][string] | undefined
): boolean {
const entry = cache[cacheKey]
return (
entry !== undefined &&
(entry.fetchedAt > requestStartedAt ||
(entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry))
)
}
function syncHostedReviewCacheFromGitHubPRResult(args: {
cache: AppState['hostedReviewCache']
repoPath: string
branch: string
settings: AppState['settings']
repoId?: string
connectionId?: string | null
executionHostId?: string | null
pr: PRInfo | null
fetchedAt: number
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
requestStartedAt?: number
requestStartedEntry?: AppState['hostedReviewCache'][string]
}): { cache: AppState['hostedReviewCache']; accepted: boolean } {
const hostedReviewCacheKey = getHostedReviewCacheKey(
args.repoPath,
args.branch,
args.settings,
args.repoId,
args.connectionId,
args.executionHostId
)
if (
args.requestStartedAt !== undefined &&
hasNewerHostedReviewCacheEntry(
args.cache,
hostedReviewCacheKey,
args.requestStartedAt,
args.requestStartedEntry
)
) {
return { cache: args.cache, accepted: false }
}
const hostedReviewEntry = args.cache[hostedReviewCacheKey]
if (
args.requestStartedAt === undefined &&
hostedReviewEntry !== undefined &&
hostedReviewEntry.fetchedAt >= args.fetchedAt
) {
return { cache: args.cache, accepted: false }
}
if (args.pr && hostedReviewEntry?.data && hostedReviewEntry.data.provider !== 'github') {
return { cache: args.cache, accepted: false }
}
if (
!args.pr &&
args.linkedPRNumber == null &&
args.fallbackPRNumber != null &&
args.fallbackPRSource !== 'hosted-review' &&
hostedReviewEntry?.data?.provider === 'github' &&
hostedReviewEntry.data.number === args.fallbackPRNumber
) {
return { cache: args.cache, accepted: false }
}
if (!args.pr && !shouldClearHostedReviewForNoGitHubPR(hostedReviewEntry)) {
return { cache: args.cache, accepted: hostedReviewEntry?.data == null }
}
return {
cache: {
...args.cache,
[hostedReviewCacheKey]: {
data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null,
fetchedAt: args.fetchedAt,
linkedReviewHintKey: args.pr
? linkedReviewHintKey({ linkedGitHubPR: args.pr.number })
: linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry)
}
},
accepted: true
}
}
function shouldWritePRCacheForHostedReviewSync(args: {
hostedReviewSyncAccepted: boolean
hostedReviewEntry: AppState['hostedReviewCache'][string] | undefined
pr: PRInfo | null
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
}): boolean {
// Why: PR-status grouping reads prCache while cards read hostedReviewCache.
// If a GitHub PR result was rejected for the card, don't let grouping drift.
if (args.hostedReviewSyncAccepted) {
return true
}
const exactPRNumber = args.linkedPRNumber ?? args.fallbackPRNumber ?? null
return (
exactPRNumber !== null &&
args.pr?.number === exactPRNumber &&
args.hostedReviewEntry?.data?.provider === 'github' &&
args.hostedReviewEntry.data.number === exactPRNumber
)
}
function shouldPreserveExistingPRForFallbackMiss(args: {
currentPR: PRInfo | null | undefined
nextPR: PRInfo | null
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
}): boolean {
// Why: fallback PR numbers come from already-visible cache, not durable
// worktree metadata. A branch/fallback miss is weaker than the current exact
// PR context, except when the fallback is the hosted-review entry being
// refreshed; that entry must not protect itself from exact misses.
return (
args.nextPR === null &&
args.linkedPRNumber == null &&
args.fallbackPRNumber != null &&
args.fallbackPRSource !== 'hosted-review' &&
args.currentPR?.number === args.fallbackPRNumber
)
}
function applyPRCacheResult(
cache: AppState['prCache'],
cacheKey: string,
pr: PRInfo | null,
fetchedAt: number,
accepted: boolean,
preserveExisting: boolean
): AppState['prCache'] {
if (preserveExisting) {
return cache
}
if (accepted) {
return withBoundedCacheEntry(cache, cacheKey, { data: pr, fetchedAt })
}
if (!cache[cacheKey]) {
return cache
}
const next = { ...cache }
delete next[cacheKey]
return next
}
function prRefreshStartedEntryKey(sequence: number, cacheKey: string): string {
return `${sequence}::${cacheKey}`
}
function deletePRRefreshStartedEntry(sequence: number | undefined, cacheKey: string): void {
if (sequence !== undefined && sequence > 0) {
prRefreshStartedHostedReviewEntries.delete(prRefreshStartedEntryKey(sequence, cacheKey))
}
}
function setPRRefreshStartedHostedReviewEntry(
key: string,
entry: AppState['hostedReviewCache'][string] | undefined
): void {
if (entry === undefined) {
prRefreshStartedHostedReviewEntries.delete(key)
return
}
prRefreshStartedHostedReviewEntries.delete(key)
prRefreshStartedHostedReviewEntries.set(key, entry)
while (prRefreshStartedHostedReviewEntries.size > PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX) {
const oldest = prRefreshStartedHostedReviewEntries.keys().next()
if (oldest.done) {
return
}
prRefreshStartedHostedReviewEntries.delete(oldest.value)
}
}
function setGitHubPRResultCaches(
state: AppState,
args: {
prCacheKey: string
repoPath: string
branch: string
settings: AppState['settings']
repoId?: string
connectionId?: string | null
executionHostId?: string | null
pr: PRInfo | null
fetchedAt: number
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
requestStartedAt?: number
requestStartedEntry?: AppState['hostedReviewCache'][string]
}
): Partial<AppState> {
const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({
cache: state.hostedReviewCache,
repoPath: args.repoPath,
branch: args.branch,
settings: args.settings,
repoId: args.repoId,
connectionId: args.connectionId,
executionHostId: args.executionHostId,
pr: args.pr,
fetchedAt: args.fetchedAt,
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber,
fallbackPRSource: args.fallbackPRSource,
requestStartedAt: args.requestStartedAt,
requestStartedEntry: args.requestStartedEntry
})
const hostedReviewCacheKey = getHostedReviewCacheKey(
args.repoPath,
args.branch,
args.settings,
args.repoId,
args.connectionId,
args.executionHostId
)
return {
prCache: applyPRCacheResult(
state.prCache,
args.prCacheKey,
args.pr,
args.fetchedAt,
shouldWritePRCacheForHostedReviewSync({
hostedReviewSyncAccepted: hostedReviewSync.accepted,
hostedReviewEntry: state.hostedReviewCache[hostedReviewCacheKey],
pr: args.pr,
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber
}),
shouldPreserveExistingPRForFallbackMiss({
currentPR: state.prCache[args.prCacheKey]?.data,
nextPR: args.pr,
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber,
fallbackPRSource: args.fallbackPRSource
})
),
...(hostedReviewSync.cache === state.hostedReviewCache
? {}
: { hostedReviewCache: hostedReviewSync.cache })
}
}
function applyGitHubPRResultToCaches(args: {
prCache: AppState['prCache']
hostedReviewCache: AppState['hostedReviewCache']
prCacheKey: string
repoPath: string
branch: string
settings: AppState['settings']
repoId?: string
connectionId?: string | null
executionHostId?: string | null
pr: PRInfo | null
fetchedAt: number
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
requestStartedAt?: number
requestStartedEntry?: AppState['hostedReviewCache'][string]
}): {
prCache: AppState['prCache']
hostedReviewCache: AppState['hostedReviewCache']
} {
const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({
cache: args.hostedReviewCache,
repoPath: args.repoPath,
branch: args.branch,
settings: args.settings,
repoId: args.repoId,
connectionId: args.connectionId,
executionHostId: args.executionHostId,
pr: args.pr,
fetchedAt: args.fetchedAt,
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber,
fallbackPRSource: args.fallbackPRSource,
requestStartedAt: args.requestStartedAt,
requestStartedEntry: args.requestStartedEntry
})
const hostedReviewCacheKey = getHostedReviewCacheKey(
args.repoPath,
args.branch,
args.settings,
args.repoId,
args.connectionId,
args.executionHostId
)
return {
prCache: applyPRCacheResult(
args.prCache,
args.prCacheKey,
args.pr,
args.fetchedAt,
shouldWritePRCacheForHostedReviewSync({
hostedReviewSyncAccepted: hostedReviewSync.accepted,
hostedReviewEntry: args.hostedReviewCache[hostedReviewCacheKey],
pr: args.pr,
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber
}),
shouldPreserveExistingPRForFallbackMiss({
currentPR: args.prCache[args.prCacheKey]?.data,
nextPR: args.pr,
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber,
fallbackPRSource: args.fallbackPRSource
})
),
hostedReviewCache: hostedReviewSync.cache
}
}
/**
* Evict the oldest entries from a cache record when it exceeds the max size.
* Returns a pruned copy, or the original reference if no eviction was needed.
*/
function evictStaleEntries<T extends { fetchedAt: number }>(
cache: Record<string, T>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, T> {
const keys = Object.keys(cache)
if (keys.length <= maxEntries) {
return cache
}
const sorted = keys
.map((k) => ({ key: k, fetchedAt: cache[k].fetchedAt }))
.sort((a, b) => b.fetchedAt - a.fetchedAt)
const keep = new Set(sorted.slice(0, maxEntries).map((e) => e.key))
const pruned: Record<string, T> = {}
for (const k of keep) {
pruned[k] = cache[k]
}
return pruned
}
function withBoundedCacheEntry<T extends { fetchedAt: number }>(
cache: Record<string, T>,
key: string,
entry: T
): Record<string, T> {
return evictStaleEntries({ ...cache, [key]: entry })
}
// Why: the prRefresh* maps are keyed by PR cache key (repo/branch/execution-host)
// — an ephemeral, unbounded key space over a long session. They have no
// `fetchedAt` to sort by, so bound them by insertion order (oldest-touched keys
// evicted first; the writers move each touched key to the end). An evicted
// long-idle branch simply restarts from a clean state, which is acceptable.
function capRecordByInsertionOrder<T>(
record: Record<string, T>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, T> {
const keys = Object.keys(record)
if (keys.length <= maxEntries) {
return record
}
const capped: Record<string, T> = {}
for (const key of keys.slice(keys.length - maxEntries)) {
capped[key] = record[key]
}
return capped
}
function capPrRefreshSequences(
sequences: Record<string, number>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, number> {
return capRecordByInsertionOrder(sequences, maxEntries)
}
// Why: prRefreshStates backs visible status pills (refreshing/queued/paused/error)
// so — unlike the invisible sequence guard — eviction must never drop an in-progress
// indicator. Bound it well above any realistic tracked-branch count, and when over
// cap evict *settled* statuses (error/skipped) first; only fall back to evicting an
// active (in-flight/queued/paused) entry as a last-resort hard memory bound that
// realistic usage never reaches. Evicted entries self-heal on the next refresh event.
const MAX_PR_REFRESH_STATE_ENTRIES = 2000
const SETTLED_PR_REFRESH_STATUSES = new Set<PRRefreshState['status']>(['error', 'skipped'])
function capPrRefreshStates(
states: Record<string, PRRefreshState>,
maxEntries = MAX_PR_REFRESH_STATE_ENTRIES
): Record<string, PRRefreshState> {
const keys = Object.keys(states)
let toEvict = keys.length - maxEntries
if (toEvict <= 0) {
return states
}
const evicted = new Set<string>()
// First pass: evict oldest settled (error/skipped) entries.
for (const key of keys) {
if (toEvict === 0) {
break
}
if (SETTLED_PR_REFRESH_STATUSES.has(states[key].status)) {
evicted.add(key)
toEvict--
}
}
// Last resort: evict oldest remaining keys to enforce the hard bound.
for (const key of keys) {
if (toEvict === 0) {
break
}
if (!evicted.has(key)) {
evicted.add(key)
toEvict--
}
}
const capped: Record<string, PRRefreshState> = {}
for (const key of keys) {
if (!evicted.has(key)) {
capped[key] = states[key]
}
}
return capped
}
function shouldRefreshIssueDecorations(state: AppState): boolean {
return (state.worktreeCardProperties ?? []).includes('issue')
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSaveCache(state: AppState): void {
if (saveTimer) {
clearTimeout(saveTimer)
}
saveTimer = setTimeout(() => {
saveTimer = null
window.api.cache.setGitHub({
cache: {
pr: state.prCache,
issue: state.issueCache
}
})
}, 1000) // Save at most once per second
}
export type GitHubSlice = {
prCache: Record<string, CacheEntry<PRInfo>>
issueCache: Record<string, CacheEntry<IssueInfo>>
checksCache: Record<string, CacheEntry<PRCheckDetail[]>>
commentsCache: Record<string, CacheEntry<PRComment[]>>
prRefreshSequences: Record<string, number>
prRefreshStates: Record<string, PRRefreshState>
prVisibleRefreshGeneration: number
// Why: keyed by repoId + limit + query so remote repos with the same path on
// different SSH targets do not share issue/PR results.
// from cache instantly on mount (and on hover-prefetch from sidebar buttons)
// while a background refresh keeps the list fresh.
workItemsCache: Record<string, CacheEntry<GitHubWorkItem[]>>
fetchPRForBranch: (
repoPath: string,
branch: string,
options?: RepoScopedFetchOptions & {
worktreeId?: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
}
) => Promise<PRInfo | null>
fetchIssue: (
repoPath: string,
number: number,
options?: RepoScopedFetchOptions
) => Promise<IssueInfo | null>
fetchPRChecks: (
repoPath: string,
prNumber: number,
branch?: string,
headSha?: string,
prRepo?: GitHubOwnerRepo | null,
options?: RepoScopedFetchOptions
) => Promise<PRCheckDetail[]>
fetchPRCheckDetails: (
repoPath: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
},
options?: RepoScopedFetchOptions
) => Promise<PRCheckRunDetails | null>
fetchPRComments: (
repoPath: string,
prNumber: number,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<PRComment[]>
addPRConversationComment: (
repoPath: string,
prNumber: number,
body: string,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<GitHubCommentResult>
addPRReviewCommentReply: (
repoPath: string,
prNumber: number,
commentId: number,
body: string,
options?: RepoScopedFetchOptions & {
prRepo?: GitHubOwnerRepo | null
threadId?: string
path?: string
line?: number
}
) => Promise<GitHubCommentResult>
resolveReviewThread: (
repoPath: string,
prNumber: number,
threadId: string,
resolve: boolean,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<boolean>
initGitHubCache: () => Promise<void>
refreshAllGitHub: () => void
refreshGitHubForWorktree: (worktreeId: string) => void
refreshGitHubForWorktreeIfStale: (worktreeId: string) => void
enqueueGitHubPRRefresh: (
worktreeId: string,
reason: GitHubPRRefreshReason,
priority?: number
) => void
reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void
bumpGitHubPRVisibleRefreshGeneration: () => void
applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void
/**
* Why: returns cached work items immediately (null if none) and fires a
* background refresh when stale. Callers can render the cached list while
* the SWR revalidate hydrates the latest.
*/
getCachedWorkItems: (
repoId: string,
limit: number,
query: string,
repoPath?: string,
sourceContext?: TaskSourceContext | null
) => GitHubWorkItem[] | null
/**
* Why: the Tasks view header reads sources from the cache to render the
* "Issues from owner/repo" indicator, and the Tasks empty/partial banner
* reads `error` here to show the retry affordance. Returning a thin view of
* the cache entry (never the items) keeps this a cheap selector the
* component can subscribe to without dragging the whole work-item array
* through the equality check.
*/
getWorkItemsSourcesAndError: (
repoId: string,
limit: number,
query: string,
repoPath?: string
) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null }
/**
* Why: the dialog renders the "Issue from owner/repo" chip for a single work
* item but may be opened before the Tasks view has populated the primary
* `(repoPath, PER_REPO_FETCH_LIMIT, '')` cache entry — e.g. when the user
* searches for an issue by query. Falls back to scanning `workItemsCache`
* for any entry keyed by `${repoPath}::` that carries resolved sources,
* returning that entry's `sources` directly. Sources are repo-level
* (query-independent), so any sibling entry is safe to reuse.
*
* Returning a single stable reference means the dialog can subscribe to just
* this selector instead of the whole `workItemsCache`, so unrelated cache
* writes don't force a re-render. Cache entries are fully replaced (not
* mutated) on every write, so reference equality is preserved between
* unchanged entries.
*/
getWorkItemsAnySourcesForRepo: (
repoId: string,
limit: number,
repoPath?: string
) => WorkItemsCacheSources | null
fetchWorkItems: (
repoId: string,
repoPath: string,
limit: number,
query: string,
options?: FetchOptions
) => Promise<GitHubWorkItem[]>
/**
* Why: fan out a single work-item query across multiple repos. Partial
* failures don't reject — a repo that both fails to fetch *and* has no
* cached fallback contributes nothing and increments `failedCount`, which
* the caller surfaces as a "N of M projects failed to load" banner. A repo
* served from stale cache on rejection is NOT counted as failed — matching
* the single-repo behavior of quietly serving stale data.
*/
fetchWorkItemsAcrossRepos: (
repos: {
repoId: string
path: string
executionHostId?: string | null
sourceContext?: TaskSourceContext | null
}[],
perRepoLimit: number,
displayLimit: number,
query: string,
options?: FetchOptions
) => Promise<{ items: GitHubWorkItem[]; failedCount: number }>
/**
* Fetch the next page of work items using a date cursor. Does not cache —
* pagination pages are ephemeral and managed by TaskPage state.
*/
fetchWorkItemsNextPage: (
repos: {
repoId: string
path: string
executionHostId?: string | null
sourceContext?: TaskSourceContext | null
}[],
perRepoLimit: number,
displayLimit: number,
query: string,
before: string
) => Promise<{ items: GitHubWorkItem[]; failedCount: number }>
/**
* Count total work items across repos using GitHub's search API.
* Returns the sum of per-repo counts for the given query.
*/
countWorkItemsAcrossRepos: (
repos: {
repoId: string
path: string
executionHostId?: string | null
sourceContext?: TaskSourceContext | null
}[],
query: string
) => Promise<number>
/**
* Fire-and-forget prefetch used by UI entry points (hover/focus of the
* "new workspace" buttons) to warm the cache before the page mounts.
*/
prefetchWorkItems: (
repoId: string,
repoPath: string,
limit?: number,
query?: string,
options?: { sourceContext?: TaskSourceContext | null }
) => void
patchWorkItem: (
itemId: string,
patch: Partial<GitHubWorkItem>,
repoId?: string | null,
options?: GitHubPatchWorkItemOptions
) => void
/**
* Monotonic counter bumped whenever a repo's issue-source preference is
* flipped. Subscribers (TaskPage's fetch effect) include this in their
* dependency array to force a re-fetch after preference changes — the
* work-items cache eviction alone isn't enough because the effect keys on
* `selectedRepos`/`appliedTaskSearch`/`taskRefreshNonce` and wouldn't
* otherwise notice the cache went empty.
*/
workItemsInvalidationNonce: number
/**
* Persist a per-repo issue-source preference, update the local Repo record
* for reactive UI, and invalidate all cached work-items entries that key
* off this repo's identity so the Tasks list re-fetches against the new source.
*
* Why invalidate all `${repoId}::*` keys and not only the primary entry:
* preferences flip the issue source for every list query (query-less +
* user-entered queries alike). Surgical eviction of the primary key alone
* would leave stale results in alternate-query cache lines.
*/
setIssueSourcePreference: (
repoId: string,
repoPath: string,
preference: IssueSourcePreference
) => Promise<void>
evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void
// ── ProjectV2 view cache ─────────────────────────────────────────────
projectViewCache: Record<string, ProjectViewCacheEntry<GitHubProjectTable>>
fetchProjectViewTable: (
args: GetProjectViewTableArgs,
options?: FetchOptions
) => Promise<GetProjectViewTableResult>
updateProjectFieldValue: (
cacheKey: string,
rowId: string,
fieldId: string,
value: GitHubProjectFieldMutationValue
) => Promise<GitHubProjectMutationResult>
clearProjectFieldValue: (
cacheKey: string,
rowId: string,
fieldId: string
) => Promise<GitHubProjectMutationResult>
patchProjectIssueOrPr: (
cacheKey: string,
rowId: string,
updates: ProjectRowContentUpdate
) => Promise<GitHubProjectMutationResult>
patchProjectRowIssueType: (
cacheKey: string,
rowId: string,
issueType: { id: string; name: string; color: string | null; description: string | null } | null
) => Promise<GitHubProjectMutationResult>
/** Optimistic, IPC-free patcher for a single `projectViewCache` row's
* `content`. Used by GitHubItemDialog when `projectOrigin` is set so the
* Project table re-renders immediately after dialog edits — `patchWorkItem`
* alone only walks `workItemsCache` and would leave the Project view stale
* until the next refresh. The actual write is dispatched separately via
* the slug-addressed update IPCs. */
patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
prCache: {},
issueCache: {},
checksCache: {},
commentsCache: {},
prRefreshSequences: {},
prRefreshStates: {},
prVisibleRefreshGeneration: 0,
workItemsCache: {},
workItemsInvalidationNonce: 0,
projectViewCache: {},
fetchProjectViewTable: async (args, options) => {
const target = getActiveRuntimeTarget(get().settings)
const sourceScope = projectViewSourceScope(get().settings)
const requestKey = projectViewRequestKey(args, sourceScope)
// Fast path: when the caller supplies `viewId`, we already know the
// resolved cache key and can serve a fresh entry directly.
const maybeKnownKey = args.viewId
? projectViewCacheKey(
args.ownerType,
args.owner,
args.projectNumber,
args.viewId,
args.queryOverride,
sourceScope
)
: null
if (!options?.force && maybeKnownKey) {
const cached = get().projectViewCache[maybeKnownKey]
if (cached?.data && Date.now() - cached.fetchedAt < WORK_ITEMS_CACHE_TTL) {
return { ok: true, data: cached.data }
}
}
const existing = inflightProjectViewRequests.get(requestKey)
if (existing) {
// Why: mirror fetchWorkItems force-refresh semantics — a forcing caller
// must not silently dedupe to a non-forcing in-flight request; wait for
// that to settle (result discarded) and then issue a fresh forced call.
if (options?.force && !existing.force) {
await existing.promise.catch(() => {})
} else {
return existing.promise
}
}
const request = (async (): Promise<GetProjectViewTableResult> => {
await acquireWorkItemSlot()
try {
const envelope =
target.kind === 'environment'
? await callRuntimeRpc<GetProjectViewTableResult>(
target,
'github.project.viewTable',
args,
{ timeoutMs: 60_000 }
)
: await window.api.gh.getProjectViewTable(args)
if (envelope.ok) {
const table = envelope.data
const key = projectViewCacheKey(
table.project.ownerType,
table.project.owner,
table.project.number,
table.selectedView.id,
args.queryOverride,
sourceScope
)
set((s) => ({
projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, {
data: table,
fetchedAt: Date.now()
})
}))
} else if (maybeKnownKey) {
// Only stamp the error onto the cache when we have a resolved key
// (i.e. caller supplied viewId). Otherwise we have nowhere to write
// it — the renderer classifies the error directly from the envelope.
set((s) => ({
projectViewCache: withBoundedCacheEntry(s.projectViewCache, maybeKnownKey, {
data: s.projectViewCache[maybeKnownKey]?.data ?? null,
fetchedAt: Date.now(),
error: envelope.error
})
}))
}
return envelope
} catch (err) {
// Why: IPC boundary must not throw across the promise — wrap any
// unexpected error in the classified envelope so the renderer has
// a single shape to render.
console.error('Failed to fetch GitHub project view:', err)
return {
ok: false,
error: {
type: 'unknown',
message: err instanceof Error ? err.message : 'Failed to fetch project view'
}
}
} finally {
releaseWorkItemSlot()
inflightProjectViewRequests.delete(requestKey)
}
})()
inflightProjectViewRequests.set(requestKey, {
promise: request,
force: Boolean(options?.force)
})
return request
},
updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.f963485d37', 'Row not found')
}
}
}
const previousRow = table.rows[rowIndex]
// Optimistic patch: build a field value matching the mutation shape.
const nextField = optimisticFieldValueFromMutation(table, fieldId, value)
const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId }
if (nextField) {
optimisticFieldValues[fieldId] = nextField
}
const optimisticRow: GitHubProjectRow = {
...previousRow,
fieldValuesByFieldId: optimisticFieldValues
}
applyRowPatch(set, cacheKey, rowId, optimisticRow)
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
const result =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.updateItemField',
{
projectId: table.project.id,
itemId: rowId,
fieldId,
value
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.updateProjectItemField({
projectId: table.project.id,
itemId: rowId,
fieldId,
value
})
if (!result.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return result
},
clearProjectFieldValue: async (cacheKey, rowId, fieldId) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.f963485d37', 'Row not found')
}
}
}
const previousRow = table.rows[rowIndex]
const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId }
delete optimisticFieldValues[fieldId]
const optimisticRow: GitHubProjectRow = {
...previousRow,
fieldValuesByFieldId: optimisticFieldValues
}
applyRowPatch(set, cacheKey, rowId, optimisticRow)
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
const result =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.clearItemField',
{
projectId: table.project.id,
itemId: rowId,
fieldId
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.clearProjectItemField({
projectId: table.project.id,
itemId: rowId,
fieldId
})
if (!result.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return result
},
patchProjectIssueOrPr: async (cacheKey, rowId, updates) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.f963485d37', 'Row not found')
}
}
}
const previousRow = table.rows[rowIndex]
const { owner, repo, number } = parseSlugAndNumber(previousRow) ?? {}
if (!owner || !repo || !number) {
return {
ok: false,
error: {
type: 'validation_error',
message: translate(
'auto.store.slices.github.87020f6605',
'Row has no owner/repo/number — cannot patch underlying item'
)
}
}
}
// Optimistic content patch.
const nextContent = { ...previousRow.content }
if (updates.title !== undefined) {
nextContent.title = updates.title
}
if (updates.body !== undefined) {
nextContent.body = updates.body
}
if (updates.addLabels || updates.removeLabels) {
const next = new Map(nextContent.labels.map((l) => [l.name, l]))
for (const name of updates.addLabels ?? []) {
if (!next.has(name)) {
next.set(name, { name, color: '808080' })
}
}
for (const name of updates.removeLabels ?? []) {
next.delete(name)
}
nextContent.labels = Array.from(next.values())
}
if (updates.addAssignees || updates.removeAssignees) {
const next = new Map(nextContent.assignees.map((u) => [u.login, u]))
for (const login of updates.addAssignees ?? []) {
if (!next.has(login)) {
next.set(login, { login, name: null, avatarUrl: null })
}
}
for (const login of updates.removeAssignees ?? []) {
next.delete(login)
}
nextContent.assignees = Array.from(next.values())
}
const optimisticRow: GitHubProjectRow = { ...previousRow, content: nextContent }
applyRowPatch(set, cacheKey, rowId, optimisticRow)
// Why: PRs and issues both accept label/assignee edits through the issue
// endpoint — GitHub PRs are issues for labels/assignees. Title/body for
// PRs goes through updatePullRequestBySlug; for issues through
// updateIssueBySlug. We dispatch both as needed.
let envelope: GitHubProjectMutationResult = { ok: true }
// Why: Project rows may be slug-only and have no registered Orca repo.
// Fall back to the view source encoded in the cache key, not focused host.
const target = getActiveRuntimeTarget(
settingsForProjectRowOwner(
get(),
owner,
repo,
settingsForProjectViewCacheKey(get().settings, cacheKey)
)
)
if (
previousRow.itemType === 'PULL_REQUEST' &&
(updates.title !== undefined || updates.body !== undefined)
) {
const args = {
owner,
repo,
number,
updates: {
...(updates.title !== undefined ? { title: updates.title } : {}),
...(updates.body !== undefined ? { body: updates.body } : {})
}
}
const prRes =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.updatePullRequestBySlug',
args,
{ timeoutMs: 30_000 }
)
: await window.api.gh.updatePullRequestBySlug(args)
if (!prRes.ok) {
envelope = prRes
}
}
if (
envelope.ok &&
(updates.addLabels?.length ||
updates.removeLabels?.length ||
updates.addAssignees?.length ||
updates.removeAssignees?.length ||
(previousRow.itemType === 'ISSUE' &&
(updates.title !== undefined || updates.body !== undefined)))
) {
const args = {
owner,
repo,
number,
updates: {
...(updates.title !== undefined ? { title: updates.title } : {}),
...(updates.body !== undefined ? { body: updates.body } : {}),
...(updates.addLabels ? { addLabels: updates.addLabels } : {}),
...(updates.removeLabels ? { removeLabels: updates.removeLabels } : {}),
...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}),
...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {})
}
}
const issueRes =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.updateIssueBySlug',
args,
{ timeoutMs: 30_000 }
)
: await window.api.gh.updateIssueBySlug(args)
if (!issueRes.ok) {
envelope = issueRes
}
}
if (!envelope.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return envelope
},
patchProjectRowIssueType: async (cacheKey, rowId, issueType) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const row = table.rows.find((r) => r.id === rowId)
if (!row) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.f963485d37', 'Row not found')
}
}
}
if (row.itemType !== 'ISSUE') {
return {
ok: false,
error: {
type: 'validation_error',
message: translate(
'auto.store.slices.github.83f9b126ad',
'Issue Type can only be set on Issues.'
)
}
}
}
const { owner, repo, number } = parseSlugAndNumber(row) ?? {}
if (!owner || !repo || !number) {
return {
ok: false,
error: {
type: 'validation_error',
message: translate('auto.store.slices.github.683a21264b', 'Row has no owner/repo/number.')
}
}
}
const previousRow = row
const optimistic: GitHubProjectRow = {
...previousRow,
content: { ...previousRow.content, issueType }
}
applyRowPatch(set, cacheKey, rowId, optimistic)
// Why: slug-only Project rows still belong to the source host that loaded
// the view; focused host may have changed after the table was fetched.
const target = getActiveRuntimeTarget(
settingsForProjectRowOwner(
get(),
owner,
repo,
settingsForProjectViewCacheKey(get().settings, cacheKey)
)
)
const args = {
owner,
repo,
number,
issueTypeId: issueType?.id ?? null
}
const res =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.updateIssueTypeBySlug',
args,
{ timeoutMs: 30_000 }
)
: await window.api.gh.updateIssueTypeBySlug(args)
if (!res.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return res
},
patchProjectRowContent: (cacheKey, rowId, patch) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return
}
const previousRow = table.rows.find((r) => r.id === rowId)
if (!previousRow) {
return
}
const nextContent = { ...previousRow.content }
if (patch.title !== undefined) {
nextContent.title = patch.title
}
if (patch.body !== undefined) {
nextContent.body = patch.body
}
if (patch.state !== undefined) {
// Why: ProjectV2 row.state mirrors GitHub's UPPERCASE state enum
// ('OPEN' | 'CLOSED' | 'MERGED'). The dialog tracks lowercase
// ('open' | 'closed') matching `GitHubWorkItem['state']`. Translate
// here so the optimistic patch matches the canonical row shape and
// the next authoritative fetch overwrites cleanly.
nextContent.state = patch.state.toUpperCase()
}
if (patch.labels !== undefined) {
const existingByName = new Map(previousRow.content.labels.map((l) => [l.name, l]))
nextContent.labels = patch.labels.map(
(name) => existingByName.get(name) ?? { name, color: '808080' }
)
}
if (patch.assignees !== undefined) {
const existingByLogin = new Map(previousRow.content.assignees.map((u) => [u.login, u]))
nextContent.assignees = patch.assignees.map(
(login) => existingByLogin.get(login) ?? { login, name: null, avatarUrl: null }
)
}
const nextRow: GitHubProjectRow = { ...previousRow, content: nextContent }
applyRowPatch(set, cacheKey, rowId, nextRow)
},
getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return null
}
const state = get()
const key =
sourceContext?.provider === 'github'
? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext))
: getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath)
return get().workItemsCache[key]?.data ?? null
},
getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return { sources: null, error: null }
}
const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath)
const entry = get().workItemsCache[key]
return {
sources: entry?.sources ?? null,
error: entry?.error ?? null
}
},
getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => {
const cache = get().workItemsCache
const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath)
const primary = cache[primaryKey]?.sources
if (primary) {
return primary
}
const prefix = primaryKey
for (const [key, entry] of Object.entries(cache)) {
if (key.startsWith(prefix) && entry.sources) {
return entry.sources
}
}
return null
},
fetchWorkItems: async (repoId, repoPath, limit, query, options): Promise<GitHubWorkItem[]> => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return []
}
const requestState = get()
const repo = findRepoForGitHubOwner(requestState, repoId, repoPath)
const requestSettings = getGitHubWorkItemSourceSettings(
requestState.settings,
repo,
options?.sourceContext
)
const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext)
const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext)
const key = workItemsCacheKey(repoId, limit, query, cacheScope)
const cached = get().workItemsCache[key]
if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) {
return cached.data ?? []
}
const requestInvalidationNonce = requestState.workItemsInvalidationNonce
const requestContext = getGitHubWorkItemRequestContext(
requestState,
requestSettings,
repoId,
repoPath,
options?.sourceContext
)
const inflightKey = workItemsInflightRequestKey(key, requestContext.target)
const existing = inflightWorkItemsRequests.get(inflightKey)
if (existing) {
// Why: a user-initiated refresh (force=true) must not silently dedupe to
// a less-fresh fetch already in flight. noCache=true is stricter than a
// cacheable forced landing probe because it must bypass gh api's cache too.
if ((options?.force && !existing.force) || (options?.noCache && !existing.noCache)) {
await existing.promise.catch(() => {})
} else {
return existing.promise
}
}
const request = (async () => {
await acquireWorkItemSlot()
try {
const envelope = await listGitHubWorkItemsForRepo(requestContext, {
limit,
query: query || undefined,
...(options?.noCache ? { noCache: true } : {})
})
// Why: stamp repoId at the renderer fetch boundary so every downstream
// consumer (cross-repo merge, row rendering, drawer) can rely on the
// field being present. Main doesn't know Orca's Repo.id.
const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId }))
// Why: only surface the issues-side error in the cache entry. The
// parent design doc §2 scopes feature 1 to the new class of silent
// wrongness introduced by the issue-source split in #1076; PR-side
// failures existed before and are out of scope for this banner.
const issuesError = envelope.errors?.issues
// Why: if the main process resolved `errors.issues` but not `sources.issues`,
// the renderer has no slug to render in the banner copy, so the error is
// dropped from the cache entry. Log it so this rare case is at least visible
// in devtools rather than disappearing silently.
if (issuesError && !envelope.sources.issues) {
console.warn(
'[workItems] dropping issues-side error with no resolved source:',
issuesError
)
}
const errorForCache: WorkItemsCacheError | undefined =
issuesError && envelope.sources.issues
? { ...issuesError, source: envelope.sources.issues }
: undefined
const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath)
const currentHostId = getGitHubWorkItemSourceHostId(
get(),
currentRepo,
options?.sourceContext
)
// Why: host focus changes are allowed, but repo ownership changes mean
// this response belongs to an older execution host bucket.
if ((currentHostId ?? null) !== (ownerHostId ?? null)) {
return items
}
// Why: clearing in-flight entries lets the next fetch start, but the
// old promise can still settle. Do not let pre-flip source data
// repopulate the cache after the invalidation nonce changes.
if (get().workItemsInvalidationNonce !== requestInvalidationNonce) {
return items
}
set((s) => ({
workItemsCache: withBoundedCacheEntry(s.workItemsCache, key, {
data: items,
fetchedAt: Date.now(),
sources: envelope.sources,
...(errorForCache ? { error: errorForCache } : {}),
...(envelope.issueSourceFellBack ? { issueSourceFellBack: true } : {})
})
}))
return items
} catch (err) {
// Why: surface the error to the caller; keep stale cache entry so the
// UI can continue to render something useful while the user retries.
if (!isGitHubWorkItemsSshRemoteRequiredError(err)) {
console.error('Failed to fetch GitHub work items:', err)
}
throw err
} finally {
releaseWorkItemSlot()
inflightWorkItemsRequests.delete(inflightKey)
}
})()
inflightWorkItemsRequests.set(inflightKey, {
promise: request,
force: Boolean(options?.force),
noCache: Boolean(options?.noCache)
})
return request
},
fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return { items: [], failedCount: 0 }
}
const state = get()
let failedCount = 0
const perProjectResults = await Promise.all(
repos.map(async (r) => {
try {
return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, {
...options,
sourceContext: r.sourceContext ?? options?.sourceContext
})
} catch (err) {
// Why: fall back to any cache entry (stale or not) before declaring
// this repo failed. Matches single-repo behavior of silently serving
// stale data on error. A repo is only counted as failed when it has
// nothing at all to contribute.
// Why: must use perRepoLimit (not displayLimit) so the cache key
// matches what fetchWorkItems wrote.
if (isGitHubWorkItemsSshRemoteRequiredError(err)) {
return [] as GitHubWorkItem[]
}
const key =
r.sourceContext?.provider === 'github'
? workItemsCacheKey(
r.repoId,
perRepoLimit,
query,
getTaskSourceCacheScope(r.sourceContext)
)
: getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path)
const cached = get().workItemsCache[key]?.data
if (cached) {
console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err)
return cached
}
console.warn(`[workItems] ${r.repoId} failed:`, err)
failedCount += 1
return [] as GitHubWorkItem[]
}
})
)
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
},
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return { items: [], failedCount: 0 }
}
let failedCount = 0
const perProjectResults = await Promise.all(
repos.map(async (r) => {
const requestState = get()
const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path)
const requestSettings = getGitHubWorkItemSourceSettings(
requestState.settings,
repo,
r.sourceContext
)
const requestContext = getGitHubWorkItemRequestContext(
requestState,
requestSettings,
r.repoId,
r.path,
r.sourceContext
)
await acquireWorkItemSlot()
try {
const envelope = await listGitHubWorkItemsForRepo(requestContext, {
limit: perRepoLimit,
query: query || undefined,
before
})
// Why: page-N partial failures don't participate in the cache's per-repo
// error banner (which is keyed on the initial-fetch cache entry). Log the
// classified issues-side error so pagination failures are at least
// observable in logs rather than silently truncating the merged list. A
// richer surface would require threading per-page errors back to the
// caller and wiring a transient pagination banner — deferred per parent
// design doc §6 scope.
if (envelope.errors?.issues) {
console.warn(
`[workItems] next page ${r.repoId} issues-side partial failure:`,
envelope.errors.issues
)
}
return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId }))
} catch (err) {
if (isGitHubWorkItemsSshRemoteRequiredError(err)) {
return [] as GitHubWorkItem[]
}
console.warn(`[workItems] next page ${r.repoId} failed:`, err)
failedCount += 1
return [] as GitHubWorkItem[]
} finally {
releaseWorkItemSlot()
}
})
)
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
},
countWorkItemsAcrossRepos: async (repos, query) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return 0
}
const counts = await Promise.all(
repos.map(async (r) => {
try {
const requestState = get()
const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path)
const requestSettings = getGitHubWorkItemSourceSettings(
requestState.settings,
repo,
r.sourceContext
)
const requestContext = getGitHubWorkItemRequestContext(
requestState,
requestSettings,
r.repoId,
r.path,
r.sourceContext
)
return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined })
} catch {
return 0
}
})
)
return counts.reduce((sum, c) => sum + c, 0)
},
prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return
}
const requestState = get()
const repo = findRepoForGitHubOwner(requestState, repoId, repoPath)
const key =
options?.sourceContext?.provider === 'github'
? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext))
: getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath)
const cached = get().workItemsCache[key]
const requestSettings = getGitHubWorkItemSourceSettings(
requestState.settings,
repo,
options?.sourceContext
)
const requestContext = getGitHubWorkItemRequestContext(
requestState,
requestSettings,
repoId,
repoPath,
options?.sourceContext
)
const inflightKey = workItemsInflightRequestKey(key, requestContext.target)
// Skip when the cache is fresh or a request is already in flight.
if (isFresh(cached, WORK_ITEMS_CACHE_TTL) || inflightWorkItemsRequests.has(inflightKey)) {
return
}
void get()
.fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext })
.catch(() => {})
},
initGitHubCache: async () => {
try {
const persisted = await window.api.cache.getGitHub()
if (persisted) {
set({
prCache: evictStaleEntries(persisted.pr || {}),
issueCache: evictStaleEntries(persisted.issue || {})
})
}
} catch (err) {
console.error('Failed to load GitHub cache from disk:', err)
}
},
fetchPRForBranch: async (repoPath, branch, options): Promise<PRInfo | null> => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = settingsForGitHubRepoOwner(get().settings, repo)
const cacheKey = prCacheKey(
repoPath,
repoId,
branch,
requestSettings,
repo?.connectionId,
repo?.executionHostId
)
const cached = get().prCache[cacheKey]
const hostedReviewCacheKey = getHostedReviewCacheKey(
repoPath,
branch,
requestSettings,
repoId,
repo?.connectionId,
repo?.executionHostId
)
// Why: if a prior caller without a linkedPR cached `null` for this branch,
// the worktree-card lookup (which has a linked PR fallback) would otherwise
// return null forever. Refetch when the cached miss could now resolve via
// the linkedPR path.
const linkedPRNumber = options?.linkedPRNumber ?? null
const explicitFallbackPRNumber = options?.fallbackPRNumber ?? null
const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber(
get(),
repoPath,
repoId,
branch,
repo?.connectionId,
repo?.executionHostId
)
const fallbackPRNumber =
linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null
const fallbackPRSource: GitHubPRFallbackSource | null =
linkedPRNumber != null || fallbackPRNumber == null
? null
: (options?.fallbackPRSource ??
(explicitFallbackPRNumber != null ? 'explicit' : 'hosted-review'))
const lookupHintKey = prLookupHintKey(linkedPRNumber, fallbackPRNumber)
const linkedRefetch =
cached?.data === null && (linkedPRNumber !== null || fallbackPRNumber !== null)
if (!options?.force && !linkedRefetch && isFresh(cached)) {
return cached.data
}
const inflightRequest = inflightPRRequests.get(cacheKey)
if (
inflightRequest &&
(!options?.force || inflightRequest.force) &&
inflightRequest.lookupHintKey === lookupHintKey &&
!linkedRefetch
) {
return inflightRequest.promise
}
const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1
const requestStartedAt = Date.now()
const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey]
prRequestGenerations.set(cacheKey, generation)
const request = (async () => {
try {
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings)
const outcome = runtimeRepo
? await callRuntimeRpc<PRInfo | null>(
runtimeRepo.target,
'github.prForBranch',
{
repo: runtimeRepo.repo.id,
branch,
linkedPRNumber,
...(fallbackPRNumber !== null
? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null }
: {})
},
{ timeoutMs: 30_000 }
).then((pr) =>
pr
? ({ kind: 'found', pr, fetchedAt: Date.now() } as const)
: ({ kind: 'no-pr', fetchedAt: Date.now() } as const)
)
: await (async () => {
const candidate: GitHubPRRefreshCandidate = {
repoId: repoId ?? '',
repoPath,
repoKind: repo?.kind ?? 'git',
branch,
cacheKey,
worktreeId: options?.worktreeId,
linkedPRNumber,
fallbackPRNumber,
fallbackPRSource,
connectionId: repo?.connectionId ?? null,
executionHostId: repo?.executionHostId ?? null,
cachedFetchedAt: cached?.fetchedAt ?? null,
cachedHasPR: cached?.data ? true : cached ? false : null,
cachedPRState: cached?.data?.state ?? null,
cachedChecksStatus: cached?.data?.checksStatus ?? null,
cachedMergeable: cached?.data?.mergeable ?? null,
cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null
}
return window.api.gh.refreshPRNow
? await window.api.gh.refreshPRNow({ candidate })
: await window.api.gh
.prForBranch({
repoPath,
repoId,
branch,
linkedPRNumber,
fallbackPRNumber,
acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null
})
.then((pr) =>
pr
? ({ kind: 'found', pr, fetchedAt: Date.now() } as const)
: ({ kind: 'no-pr', fetchedAt: Date.now() } as const)
)
})()
const pr: PRInfo | null =
outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null
if (outcome.kind === 'upstream-error') {
return cached?.data ?? null
}
if (prRequestGenerations.get(cacheKey) === generation) {
let skippedStaleLinkedPRLookup = false
set((s) => {
// Why: unlinking a PR while an exact linked-PR lookup is in flight
// must prevent that older result from restoring the manual link UI.
if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) {
skippedStaleLinkedPRLookup = true
return {}
}
return setGitHubPRResultCaches(s, {
prCacheKey: cacheKey,
repoPath,
branch,
settings: requestSettings,
repoId,
connectionId: repo?.connectionId,
executionHostId: repo?.executionHostId,
pr,
fetchedAt: outcome.fetchedAt,
linkedPRNumber,
fallbackPRNumber,
fallbackPRSource,
requestStartedAt,
requestStartedEntry: requestStartedHostedReviewEntry
})
})
if (skippedStaleLinkedPRLookup) {
return null
}
debouncedSaveCache(get())
}
if (
shouldPreserveExistingPRForFallbackMiss({
currentPR: get().prCache[cacheKey]?.data,
nextPR: pr,
linkedPRNumber,
fallbackPRNumber,
fallbackPRSource
})
) {
return get().prCache[cacheKey]?.data ?? null
}
return pr ?? null
} catch (err) {
console.error('Failed to fetch PR:', err)
return null
} finally {
const activeRequest = inflightPRRequests.get(cacheKey)
if (activeRequest?.generation === generation) {
inflightPRRequests.delete(cacheKey)
if (prRequestGenerations.get(cacheKey) === generation) {
prRequestGenerations.delete(cacheKey)
}
}
}
})()
inflightPRRequests.set(cacheKey, {
promise: request,
force: Boolean(options?.force),
generation,
lookupHintKey
})
return request
},
fetchIssue: async (repoPath, number, options) => {
const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
String(number),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
const cached = get().issueCache[cacheKey]
if (isFresh(cached)) {
return cached.data
}
const inflightRequest = inflightIssueRequests.get(cacheKey)
if (inflightRequest) {
return inflightRequest
}
const request = (async () => {
try {
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
const issue =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<IssueInfo | null>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.issue',
{ repo: requestContext.target.runtimeRepoId, number },
{ timeoutMs: 30_000 }
)
: await window.api.gh.issue({
repoPath,
repoId,
number,
sourceContext: options?.sourceContext
})
set((s) => ({
issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, {
data: issue,
fetchedAt: Date.now()
})
}))
debouncedSaveCache(get())
return issue
} catch (err) {
console.error('Failed to fetch issue:', err)
set((s) => ({
issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, {
data: null,
fetchedAt: Date.now()
})
}))
debouncedSaveCache(get())
return null
} finally {
inflightIssueRequests.delete(cacheKey)
}
})()
inflightIssueRequests.set(cacheKey, request)
return request
},
fetchPRChecks: async (
repoPath,
prNumber,
branch,
headSha,
prRepo,
options
): Promise<PRCheckDetail[]> => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
prChecksCacheSuffix(prNumber, prRepo, headSha),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
const legacyCacheKey = headSha
? sourceScopedRepoCacheKey(
repoPath,
repoId,
prChecksCacheSuffix(prNumber, prRepo),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
: cacheKey
const inflightKey = cacheKey
const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
if (
!options?.force &&
!options?.noCache &&
isFresh(cached, getPRChecksCacheTtl(cached)) &&
(!headSha || cached.headSha === headSha)
) {
const cachedChecks = cached.data ?? []
const prStatusUpdate = syncPRChecksStatus(
get(),
repoPath,
repoId,
branch,
cachedChecks,
cached.headSha,
prRepo,
requestSettings,
repo?.connectionId,
repo?.executionHostId
)
if (prStatusUpdate) {
set(prStatusUpdate)
debouncedSaveCache(get())
}
return cachedChecks
}
const inflightRequest = inflightChecksRequests.get(inflightKey)
if (inflightRequest) {
if (
(options?.force && !inflightRequest.force) ||
(options?.noCache && !inflightRequest.noCache)
) {
await inflightRequest.promise.catch(() => {})
} else {
return inflightRequest.promise
}
}
const request = (async () => {
try {
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
const checks =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<PRCheckDetail[]>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.prChecks',
{
repo: requestContext.target.runtimeRepoId,
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: Boolean(options?.force || options?.noCache)
},
{ timeoutMs: 30_000 }
)
: ((await window.api.gh.prChecks({
repoPath,
repoId,
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: Boolean(options?.force || options?.noCache),
sourceContext: options?.sourceContext
})) as PRCheckDetail[])
set((s) => {
const nextState: Partial<AppState> = {
checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, {
data: checks,
fetchedAt: Date.now(),
headSha
})
}
const prStatusUpdate = syncPRChecksStatus(
s,
repoPath,
repoId,
branch,
checks,
headSha,
prRepo,
requestSettings,
repo?.connectionId,
repo?.executionHostId
)
if (prStatusUpdate?.prCache) {
nextState.prCache = prStatusUpdate.prCache
}
return nextState
})
debouncedSaveCache(get())
return checks
} catch (err) {
console.error('Failed to fetch PR checks:', err)
const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
if (latestCached?.data && (!headSha || latestCached.headSha === headSha)) {
return latestCached.data
}
return []
} finally {
inflightChecksRequests.delete(inflightKey)
}
})()
inflightChecksRequests.set(inflightKey, {
promise: request,
force: Boolean(options?.force),
noCache: Boolean(options?.force || options?.noCache)
})
return request
},
fetchPRCheckDetails: async (repoPath, args, options): Promise<PRCheckRunDetails | null> => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
return requestContext.target.kind === 'environment'
? await callRuntimeRpc<PRCheckRunDetails | null>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.prCheckDetails',
{
repo: requestContext.target.runtimeRepoId,
checkRunId: args.checkRunId,
workflowRunId: args.workflowRunId,
checkName: args.checkName,
url: args.url,
prRepo: args.prRepo ?? null
},
{ timeoutMs: 30_000 }
)
: ((await window.api.gh.prCheckDetails({
repoPath,
repoId,
checkRunId: args.checkRunId,
workflowRunId: args.workflowRunId,
checkName: args.checkName,
url: args.url,
prRepo: args.prRepo ?? null,
sourceContext: options?.sourceContext
})) as PRCheckRunDetails | null)
},
fetchPRComments: async (repoPath, prNumber, options): Promise<PRComment[]> => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
const cached = get().commentsCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? []
}
const inflightRequest = inflightCommentsRequests.get(cacheKey)
if (inflightRequest) {
return inflightRequest
}
const request = (async () => {
try {
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
const comments =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<PRComment[]>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.prComments',
{
repo: requestContext.target.runtimeRepoId,
prNumber,
prRepo: options?.prRepo ?? null,
noCache: options?.force
},
{ timeoutMs: 30_000 }
)
: ((await window.api.gh.prComments({
repoPath,
repoId,
prNumber,
prRepo: options?.prRepo ?? null,
noCache: options?.force,
sourceContext: options?.sourceContext
})) as PRComment[])
set((s) => ({
commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, {
data: comments,
fetchedAt: Date.now()
})
}))
return comments
} catch (err) {
console.error('Failed to fetch PR comments:', err)
return get().commentsCache[cacheKey]?.data ?? []
} finally {
inflightCommentsRequests.delete(cacheKey)
}
})()
inflightCommentsRequests.set(cacheKey, request)
return request
},
addPRConversationComment: async (repoPath, prNumber, body, options) => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
let result: GitHubCommentResult
try {
result =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<GitHubCommentResult>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.addIssueComment',
{
repo: requestContext.target.runtimeRepoId,
number: prNumber,
body,
type: 'pr',
prRepo: options?.prRepo ?? null
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.addIssueComment({
repoPath,
repoId,
number: prNumber,
body,
type: 'pr',
prRepo: options?.prRepo ?? null,
sourceContext: options?.sourceContext
})
} catch (err) {
const error = err instanceof Error ? err.message : 'Failed to post comment.'
return { ok: false, error }
}
if (!hasUsableCommentPayload(result)) {
return result.ok
? {
ok: false,
error: translate(
'auto.store.slices.github.f129c42773',
'GitHub did not return the new comment.'
)
}
: result
}
set((s) => {
const entry = s.commentsCache[cacheKey]
return {
commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, {
data: mergePRCommentIntoList(entry?.data, result.comment),
fetchedAt: Date.now()
})
}
})
return result
},
addPRReviewCommentReply: async (repoPath, prNumber, commentId, body, options) => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
let result: GitHubCommentResult
try {
result =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<GitHubCommentResult>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.addPRReviewCommentReply',
{
repo: requestContext.target.runtimeRepoId,
prNumber,
commentId,
body,
threadId: options?.threadId,
path: options?.path,
line: options?.line,
prRepo: options?.prRepo ?? null
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.addPRReviewCommentReply({
repoPath,
repoId,
prNumber,
commentId,
body,
threadId: options?.threadId,
path: options?.path,
line: options?.line,
prRepo: options?.prRepo ?? null,
sourceContext: options?.sourceContext
})
} catch (err) {
const error = err instanceof Error ? err.message : 'Failed to post reply.'
return { ok: false, error }
}
if (!hasUsableCommentPayload(result)) {
return result.ok
? {
ok: false,
error: translate(
'auto.store.slices.github.f129c42773',
'GitHub did not return the new comment.'
)
}
: result
}
const comment: PRComment = {
...result.comment,
threadId: result.comment.threadId ?? options?.threadId,
path: result.comment.path ?? options?.path,
line: result.comment.line ?? options?.line
}
set((s) => {
const entry = s.commentsCache[cacheKey]
return {
commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, {
data: mergePRCommentIntoList(entry?.data, comment),
fetchedAt: Date.now()
})
}
})
return { ok: true, comment }
},
resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubWorkItemSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext
)
// Optimistic update: toggle isResolved on all comments in this thread immediately
// so the UI feels instant. Reverts if the API call fails.
const prev = get().commentsCache[cacheKey]?.data
if (prev) {
set((s) => ({
commentsCache: {
...s.commentsCache,
[cacheKey]: {
...s.commentsCache[cacheKey],
data: prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c))
}
}
}))
}
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
let ok = false
try {
ok =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<boolean>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.resolveReviewThread',
{ repo: requestContext.target.runtimeRepoId, threadId, resolve },
{ timeoutMs: 30_000 }
)
: await window.api.gh.resolveReviewThread({
repoPath,
repoId,
threadId,
resolve,
sourceContext: options?.sourceContext
})
} catch (err) {
console.error('Failed to update review thread:', err)
ok = false
}
if (!ok && prev) {
// Revert optimistic update on failure
set((s) => ({
commentsCache: {
...s.commentsCache,
[cacheKey]: { ...s.commentsCache[cacheKey], data: prev }
}
}))
}
return ok
},
enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => {
const state = get()
const worktree = findWorktreeById(state, worktreeId)
const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null
if (!candidate) {
return
}
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: bypassesGitHubPRRefreshFreshness(reason),
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
})
return
}
if (!shouldEnqueueLocalPRRefresh(candidate)) {
return
}
const enqueue = window.api.gh.enqueuePRRefresh
if (enqueue) {
void enqueue({ candidate, reason, priority })
.then((queued) => {
if (queued === false) {
return get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: bypassesGitHubPRRefreshFreshness(reason),
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
})
}
return null
})
.catch((err) => {
console.warn('Failed to enqueue PR refresh:', err)
})
}
},
reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => {
const state = get()
const candidates = worktreeIds
.map((id) => {
const worktree = findWorktreeById(state, id)
return worktree ? buildPRRefreshCandidate(state, worktree) : null
})
.filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null)
const localCandidates: GitHubPRRefreshCandidate[] = []
for (const candidate of candidates) {
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
})
continue
}
if (shouldEnqueueLocalPRRefresh(candidate)) {
localCandidates.push(candidate)
}
}
const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates
if (reportVisible) {
void reportVisible({ candidates: localCandidates, generation }).catch((err) => {
console.warn('Failed to report visible PR refresh candidates:', err)
})
}
},
bumpGitHubPRVisibleRefreshGeneration: () => {
set((s) => ({ prVisibleRefreshGeneration: s.prVisibleRefreshGeneration + 1 }))
},
applyGitHubPRRefreshEvent: (event) => {
set((s) => {
const nextSequences = { ...s.prRefreshSequences }
const nextStates = { ...s.prRefreshStates }
let nextPRCache = s.prCache
let nextHostedReviewCache = s.hostedReviewCache ?? {}
let changed = false
for (const alias of event.aliases) {
const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias)
const previousSequence = nextSequences[alias.cacheKey] ?? 0
if (
event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence
) {
if (event.outcome || event.status !== 'in-flight') {
deletePRRefreshStartedEntry(event.sequence, alias.cacheKey)
}
continue
}
// Why: delete-then-set moves this key to the end of insertion order so
// capPrRefreshSequences evicts genuinely idle keys, not active ones.
delete nextSequences[alias.cacheKey]
nextSequences[alias.cacheKey] = event.sequence
changed = true
if (event.outcome) {
const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey)
const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey)
prRefreshStartedHostedReviewEntries.delete(startedEntryKey)
if (previousSequence !== event.sequence) {
deletePRRefreshStartedEntry(previousSequence, alias.cacheKey)
}
delete nextStates[alias.cacheKey]
if (event.outcome.kind === 'upstream-error') {
nextStates[alias.cacheKey] = {
status: 'error',
reason: event.reason,
updatedAt: Date.now(),
message: event.outcome.message
}
continue
}
const data =
event.outcome.kind === 'found'
? (() => {
const pr = event.outcome.pr
const checksCacheKeys = [
...(alias.repoId
? [
...(pr.headSha
? [
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId,
aliasExecutionHostId
)
]
: []),
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
prChecksCacheSuffix(pr.number, pr.prRepo),
s.settings,
alias.connectionId,
aliasExecutionHostId
)
]
: []),
...(pr.headSha
? [
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId,
aliasExecutionHostId
)
]
: []),
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
prChecksCacheSuffix(pr.number, pr.prRepo),
s.settings,
alias.connectionId,
aliasExecutionHostId
),
`${alias.repoPath}::pr-checks::${pr.number}`
]
const checksEntry = checksCacheKeys
.map((key) => s.checksCache[key])
.find((entry) => entry?.data)
if (
checksEntry?.data &&
checksEntry.headSha &&
pr.headSha &&
checksEntry.headSha === pr.headSha &&
event.outcome.fetchedAt - checksEntry.fetchedAt <
getPRChecksCacheTtl(checksEntry)
) {
return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) }
}
return pr
})()
: null
// Why: queued local refreshes may finish after the user unlinks an
// exact PR; those older results must not restore the manual-link UI.
if (isStaleExactLinkedPRLookup(s, alias.worktreeId, alias.linkedPRNumber)) {
continue
}
const nextCaches = applyGitHubPRResultToCaches({
prCache: nextPRCache,
hostedReviewCache: nextHostedReviewCache,
prCacheKey: alias.cacheKey,
repoPath: alias.repoPath,
branch: alias.branch,
settings: s.settings,
repoId: alias.repoId,
connectionId: alias.connectionId,
executionHostId: aliasExecutionHostId,
pr: data,
fetchedAt: event.outcome.fetchedAt,
linkedPRNumber: alias.linkedPRNumber,
fallbackPRNumber: alias.fallbackPRNumber,
fallbackPRSource: alias.fallbackPRSource,
requestStartedAt: event.requestStartedAt,
requestStartedEntry
})
nextPRCache = nextCaches.prCache
nextHostedReviewCache = nextCaches.hostedReviewCache
continue
}
if (event.status) {
if (previousSequence !== event.sequence) {
deletePRRefreshStartedEntry(previousSequence, alias.cacheKey)
}
if (event.status === 'in-flight' && event.requestStartedAt !== undefined) {
const hostedReviewCacheKey = getHostedReviewCacheKey(
alias.repoPath,
alias.branch,
s.settings,
alias.repoId,
alias.connectionId,
aliasExecutionHostId
)
setPRRefreshStartedHostedReviewEntry(
prRefreshStartedEntryKey(event.sequence, alias.cacheKey),
s.hostedReviewCache[hostedReviewCacheKey]
)
} else {
// Why: rate-limit pauses/skips can follow an in-flight broadcast
// without an outcome; the cached request-start snapshot is no
// longer live and would otherwise accumulate per refresh sequence.
deletePRRefreshStartedEntry(event.sequence, alias.cacheKey)
}
// Why: delete-then-set moves this key to the end of insertion order so
// capRecordByInsertionOrder evicts genuinely idle keys, not active ones.
delete nextStates[alias.cacheKey]
nextStates[alias.cacheKey] = {
status: event.status,
reason: event.reason,
updatedAt: Date.now(),
pausedUntil: event.pausedUntil
}
}
}
return changed
? {
prRefreshSequences: capPrRefreshSequences(nextSequences),
// Why: bound prRefreshStates too (same unbounded PR-cache-key space),
// but with status-aware eviction so visible in-progress pills survive.
prRefreshStates: capPrRefreshStates(nextStates),
prCache: nextPRCache,
hostedReviewCache: nextHostedReviewCache
}
: {}
})
if (event.outcome && event.outcome.kind !== 'upstream-error') {
debouncedSaveCache(get())
}
},
refreshAllGitHub: () => {
// Invalidate comments cache so it refreshes on next access.
// Also evict old entries from retained caches to prevent unbounded growth
// across many repos and branches over a long-running session.
set((s) => ({
commentsCache: {},
prCache: evictStaleEntries(s.prCache),
issueCache: evictStaleEntries(s.issueCache),
checksCache: evictStaleEntries(s.checksCache),
workItemsCache: evictStaleEntries(s.workItemsCache),
projectViewCache: evictStaleEntries(s.projectViewCache)
}))
// Why: prRequestGenerations tracks only live inflight fetches and is
// cleared when the active request settles. Do not prune it here; deleting
// a live generation would make the corresponding response look stale.
// Only re-fetch PR/issue entries that are already stale — skip fresh ones
const state = get()
const now = Date.now()
const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = []
const cardProps = state.worktreeCardProperties ?? []
const rawCardProps = cardProps as readonly string[]
const shouldRefreshIssues = shouldRefreshIssueDecorations(state)
const isPRStatusGrouping = state.groupBy === 'pr-status'
const rightSidebarShowsPR = rightSidebarShowsPullRequestData(state)
const shouldRefreshPRs =
isPRStatusGrouping ||
rightSidebarShowsPR ||
(state.settings?.experimentalNewWorktreeCardStyle === true
? cardProps.includes('status')
: cardProps.includes('pr') || rawCardProps.includes('ci'))
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const wt of worktrees) {
const repo = state.repos.find((r) => r.id === wt.repoId)
if (!repo) {
continue
}
const branch = wt.branch.replace(/^refs\/heads\//, '')
if (shouldRefreshPRs && !wt.isBare && branch) {
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
const prKey = prCacheKey(
repo.path,
repo.id,
branch,
ownerSettings,
repo.connectionId,
repo.executionHostId
)
const prEntry = state.prCache[prKey]
if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) {
const candidate = buildPRRefreshCandidate(state, wt)
if (candidate) {
stalePRCandidates.push({
candidate,
score:
(state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) +
wt.lastActivityAt
})
}
}
}
if (shouldRefreshIssues && wt.linkedIssue) {
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
const issueKey = issueCacheKey(
repo.path,
repo.id,
wt.linkedIssue,
ownerSettings,
repo.connectionId,
repo.executionHostId
)
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id })
}
}
}
}
const candidatesToRefresh = stalePRCandidates
.sort((a, b) => b.score - a.score)
.slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5)
for (const { candidate } of candidatesToRefresh) {
const candidateSettings = settingsForGitHubRepoOwner(
state.settings,
candidate as Pick<Repo, 'connectionId' | 'executionHostId'>
)
if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 })
}
}
},
refreshGitHubForWorktree: (worktreeId) => {
const state = get()
let worktree: Worktree | undefined
for (const worktrees of Object.values(state.worktreesByRepo)) {
worktree = worktrees.find((w) => w.id === worktreeId)
if (worktree) {
break
}
}
if (!worktree) {
return
}
const repo = state.repos.find((r) => r.id === worktree.repoId)
if (!repo) {
return
}
// Invalidate this worktree's cache entries
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
const prKey = prCacheKey(
repo.path,
repo.id,
branch,
ownerSettings,
repo.connectionId,
repo.executionHostId
)
const issueKey = worktree.linkedIssue
? issueCacheKey(
repo.path,
repo.id,
worktree.linkedIssue,
ownerSettings,
repo.connectionId,
repo.executionHostId
)
: ''
set((s) => {
const updates: Partial<AppState> = {}
if (s.prCache[prKey]) {
updates.prCache = { ...s.prCache, [prKey]: { ...s.prCache[prKey], fetchedAt: 0 } }
}
if (issueKey && s.issueCache[issueKey]) {
updates.issueCache = {
...s.issueCache,
[issueKey]: { ...s.issueCache[issueKey], fetchedAt: 0 }
}
}
return updates
})
// Re-fetch (skip when branch is empty — detached HEAD during rebase)
if (!worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(get(), worktree)
if (candidate) {
if (getPRRefreshRuntimeRepoTarget(get(), candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 })
}
}
}
if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) {
void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
}
},
patchWorkItem: (itemId, patch, repoId, options) => {
set((s) => {
const nextCache = { ...s.workItemsCache }
let changed = false
const sourceScope =
options?.sourceContext?.provider === 'github'
? getTaskSourceCacheScope(options.sourceContext)
: null
for (const key of Object.keys(nextCache)) {
// Why: task edits from one host/account must not optimistically patch
// another host's visually identical GitHub issue or PR cache entry.
if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) {
continue
}
const entry = nextCache[key]
if (!entry?.data) {
continue
}
// Why: GitHub issue/PR ids are only unique within a repo. Cross-repo
// task views can contain the same `pr:42` id from multiple repos.
const idx = entry.data.findIndex(
(item) => item.id === itemId && (!repoId || item.repoId === repoId)
)
if (idx === -1) {
continue
}
const updatedItems = [...entry.data]
updatedItems[idx] = { ...updatedItems[idx], ...patch }
nextCache[key] = { ...entry, data: updatedItems }
changed = true
}
return changed ? { workItemsCache: nextCache } : {}
})
},
setIssueSourcePreference: async (repoId, repoPath, preference) => {
// Why: optimistically patch the local Repo first so the segmented control
// reflects the new selection on the same frame. On IPC failure we resync
// from disk via `fetchRepos()` below so the UI doesn't lie about what's
// persisted.
set((s) => ({
repos: s.repos.map((r) =>
r.id === repoId
? {
...r,
issueSourcePreference: preference === 'auto' ? undefined : preference
}
: r
)
}))
try {
// Why: persist via the generic `repos:update` channel rather than a
// dedicated gh-namespaced handler. Single write path → single
// `repos:changed` broadcast → other windows re-fetch. The store layer
// normalizes `'auto'` to `undefined` so the persisted record drops
// the key entirely (see main/persistence.ts#updateRepo).
const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference }
// Why: persist to the repo's owner host (same routing as updateRepo) so the
// write lands where the repo lives, not on the focused runtime.
const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId))
await (target.kind === 'local'
? window.api.repos.update({ repoId, updates })
: callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 }))
} catch (err) {
console.error('Failed to persist issue-source preference:', err)
// Why: surface the persist failure so the user understands why the
// pill visually reverts (optimistic patch above → resync via
// fetchRepos below). Without this toast, the UI silently snaps back
// and the user has no clue the write failed.
toast.error(
translate('auto.store.slices.github.d49ef4b944', 'Failed to save issue-source preference'),
{
duration: ERROR_TOAST_DURATION
}
)
// Why: the optimistic patch above may now disagree with disk. Resync
// rather than leave a lie on screen. We only refetch repos — the cache
// eviction below is still safe to run; worst case we trigger a
// harmless re-fetch of work items against the pre-flip preference.
void get().fetchRepos()
}
// Why: wipe in-flight dedupe entries for this repo BEFORE bumping the
// invalidation nonce. The bump triggers a re-run of TaskPage's fetch
// effect; if the inflight map still held a pre-flip entry, the new
// dispatch could collapse onto it and skip the source swap. Clearing
// first makes the "new fetch gets a fresh request" invariant impossible
// to trip on later refactors that change zustand or React flush timing.
clearInflightWorkItemsForRepo(repoId, repoPath)
// Why: evict every cache entry keyed on this repo AFTER the IPC
// resolves. If we evicted before awaiting, an overlapping fetch triggered
// by a different subscriber would hit main with the pre-flip persisted
// preference and repopulate the cache with stale-source data. Work-items
// cache keys are repo-scoped, but we also drop legacy path-scoped entries
// that may have been restored from older persisted cache data.
set((s) => {
const prefix = `${repoId}::`
const legacyPrefix = `${repoPath}::`
const next: Record<string, CacheEntry<GitHubWorkItem[]>> = {}
for (const [key, entry] of Object.entries(s.workItemsCache)) {
if (!key.startsWith(prefix) && !key.startsWith(legacyPrefix)) {
next[key] = entry
}
}
// Why: bump the invalidation nonce so the Tasks list's fetch effect
// — which keys on `[selectedRepos, appliedTaskSearch, taskRefreshNonce,
// taskSource, workItemsInvalidationNonce]` — re-runs and re-populates
// the just-evicted entries. Evicting alone wouldn't trigger the effect
// because it doesn't depend on the cache.
return { workItemsCache: next, workItemsInvalidationNonce: s.workItemsInvalidationNonce + 1 }
})
},
evictGitHubRepoCaches: (repoId, repoPath) => {
clearInflightWorkItemsForRepo(repoId, repoPath)
set((s) => {
const prefixes = repoCacheKeyPrefixes(repoId, repoPath)
const workItems = evictRepoCacheEntries(s.workItemsCache, prefixes)
const prs = evictRepoCacheEntries(s.prCache, prefixes)
const issues = evictRepoCacheEntries(s.issueCache, prefixes)
const checks = evictRepoCacheEntries(s.checksCache, prefixes)
const comments = evictRepoCacheEntries(s.commentsCache, prefixes)
const updates: Partial<AppState> = {}
if (workItems.evicted) {
updates.workItemsCache = workItems.cache
updates.workItemsInvalidationNonce = s.workItemsInvalidationNonce + 1
}
if (prs.evicted) {
updates.prCache = prs.cache
}
if (issues.evicted) {
updates.issueCache = issues.cache
}
if (checks.evicted) {
updates.checksCache = checks.cache
}
if (comments.evicted) {
updates.commentsCache = comments.cache
}
return updates
})
},
// Why: activation is the user's strongest freshness signal. A PR can merge
// seconds after the last sidebar poll; enqueue through the coordinator so
// clicks revalidate PR state without bypassing coalescing/rate-limit guards.
refreshGitHubForWorktreeIfStale: (worktreeId) => {
const state = get()
let worktree: Worktree | undefined
for (const worktrees of Object.values(state.worktreesByRepo)) {
worktree = worktrees.find((w) => w.id === worktreeId)
if (worktree) {
break
}
}
if (!worktree) {
return
}
const repo = state.repos.find((r) => r.id === worktree.repoId)
if (!repo) {
return
}
const now = Date.now()
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const cardProps = state.worktreeCardProperties ?? []
const rawCardProps = cardProps as readonly string[]
const shouldRefreshPR =
state.groupBy === 'pr-status' ||
(state.settings?.experimentalNewWorktreeCardStyle === true
? cardProps.includes('status')
: cardProps.includes('pr') || rawCardProps.includes('ci')) ||
rightSidebarShowsPullRequestData(state)
if (shouldRefreshPR && !worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(state, worktree)
if (candidate) {
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 })
}
}
}
if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) {
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
const issueKey = issueCacheKey(
repo.path,
repo.id,
worktree.linkedIssue,
ownerSettings,
repo.connectionId,
repo.executionHostId
)
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
}
}
}
})