From d1650343b1c9eb98e43cf4a7e9df148b11e099bb Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 11 Mar 2026 23:52:31 +0200 Subject: [PATCH] refactor: make GitHub PR status more reliable and up to date (#613) * fix: avoid incorrect PR status on default branch Hide sidebar PR status when current branch is the repo default branch. Tighten PR fallback matching to avoid picking unrelated PRs with the same branch name. * fix: make sidebar PR status more reliable Resolve PRs more reliably across remotes and repo networks. Persist sidebar PR status across tab reloads while keeping updates fresh. Reduce noisy GitHub errors and correct blocked styling in PR view. * fix: correct blocked PR styling in sidebar Stop treating missing merge permissions as a blocked PR state in the sidebar. Align sidebar PR status colors and labels with the main PR view. * refactor: make GitHub PR status updates feel more current Refresh PR state on focus, visibility, and repo changes Add follow-up PR refreshes after create, merge, ready, and update actions Document GitHub PR resolution, consumers, polling, and triggers --- .../src/components/session/SessionSidebar.tsx | 7 +- .../session/sidebar/SessionGroupSection.tsx | 6 +- .../views/git/PullRequestSection.tsx | 49 +- .../hooks/useGitHubPrBackgroundTracking.ts | 236 ++++- packages/ui/src/lib/api/types.ts | 2 + .../ui/src/stores/useGitHubPrStatusStore.ts | 958 ++++++++++-------- packages/web/server/index.js | 121 +-- .../web/server/lib/github/DOCUMENTATION.md | 189 +++- packages/web/server/lib/github/pr-status.js | 478 +++++++++ 9 files changed, 1462 insertions(+), 584 deletions(-) create mode 100644 packages/web/server/lib/github/pr-status.js diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 12c1baa4..1f77c654 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -100,7 +100,8 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState return 'draft'; } const checksFailed = status?.checks?.state === 'failure'; - const notMergeable = status?.canMerge === false || pr.mergeable === false; + const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : ''; + const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty'; if (checksFailed || notMergeable) { return 'blocked'; } @@ -847,8 +848,8 @@ export const SessionSidebar: React.FC = ({ const result = new Map(); Object.values(prStatusEntries).forEach((entry) => { - const directory = normalizePath(entry.params?.directory ?? null); - const branch = entry.params?.branch?.trim(); + const directory = normalizePath(entry.params?.directory ?? entry.identity?.directory ?? null); + const branch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim(); if (!directory || !branch) { return; } diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index a2ebf80c..d563567b 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -232,9 +232,9 @@ export function SessionGroupSection(props: Props): React.ReactNode { ].filter((item): item is string => Boolean(item)).join(', ') : null; const mergeabilityLabel = prIndicator && prIndicator.state === 'open' - ? (prIndicator.canMerge === true - ? 'Mergeable' - : (prIndicator.canMerge === false ? 'Conflicts or blocked' : null)) + ? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty' + ? 'Conflicts or blocked' + : (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? 'Mergeable' : null)) : null; const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState ? `Merge state: ${prIndicator.mergeableState}` diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 15d69bc7..e6751e54 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -93,13 +93,16 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'op return 'draft'; } const checksFailed = status?.checks?.state === 'failure'; - const notMergeable = status?.canMerge === false || pr.mergeable === false; + const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : ''; + const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty'; if (checksFailed || notMergeable) { return 'blocked'; } return 'open'; }; +const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const; + const branchToTitle = (branch: string): string => { return branch .replace(/^refs\/heads\//, '') @@ -449,6 +452,7 @@ export const PullRequestSection: React.FC<{ const lastSyncedPrNumberRef = React.useRef(null); const didUserOverrideRemoteRef = React.useRef(false); const autoRemoteProbeDoneRef = React.useRef>(new Set()); + const pendingActionRefreshTimersRef = React.useRef([]); const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); @@ -957,6 +961,15 @@ export const PullRequestSection: React.FC<{ await refreshPrStatus(prStatusKey, options); }, [prStatusKey, refreshPrStatus]); + const scheduleActionRefresh = React.useCallback(() => { + pendingActionRefreshTimersRef.current.forEach((timerId) => { + window.clearTimeout(timerId); + }); + pendingActionRefreshTimersRef.current = PR_ACTION_REFRESH_DELAYS_MS.map((delayMs) => window.setTimeout(() => { + void refresh({ force: true, silent: true, markInitialResolved: true }); + }, delayMs)); + }, [refresh]); + // Refetch PR status when selected remote changes const handleRemoteChange = React.useCallback((remote: GitRemote) => { didUserOverrideRemoteRef.current = true; @@ -1069,6 +1082,18 @@ export const PullRequestSection: React.FC<{ void refresh({ force: true, silent: true, markInitialResolved: true }); }, [canShow, refresh, selectedRemote?.name]); + React.useEffect(() => { + const resolvedRemoteName = status?.resolvedRemoteName?.trim(); + if (!resolvedRemoteName || didUserOverrideRemoteRef.current) { + return; + } + const resolvedRemote = remotes.find((candidate) => candidate.name === resolvedRemoteName); + if (!resolvedRemote) { + return; + } + setSelectedRemote((prev) => (prev?.name === resolvedRemote.name ? prev : resolvedRemote)); + }, [remotes, status?.resolvedRemoteName]); + React.useEffect(() => { const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged'; const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0; @@ -1116,6 +1141,16 @@ export const PullRequestSection: React.FC<{ }); }, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]); + React.useEffect(() => { + const pendingActionRefreshTimers = pendingActionRefreshTimersRef.current; + return () => { + pendingActionRefreshTimers.forEach((timerId) => { + window.clearTimeout(timerId); + }); + pendingActionRefreshTimersRef.current = []; + }; + }, []); + const generateDescription = React.useCallback(async () => { if (isGenerating) return; if (!directory) return; @@ -1182,13 +1217,14 @@ export const PullRequestSection: React.FC<{ toast.success('PR created'); updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev)); await refresh({ force: true }); + scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to create PR', { description: message }); } finally { setIsCreating(false); } - }, [body, branch, directory, draft, github, prStatusKey, refresh, selectedRemote, targetBaseBranch, title, updatePrStatus]); + }, [body, branch, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, updatePrStatus]); const mergePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prMerge) { @@ -1204,6 +1240,7 @@ export const PullRequestSection: React.FC<{ toast.message('PR not merged', { description: result.message || 'Not mergeable' }); } await refresh({ force: true }); + scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Merge failed', { description: message }); @@ -1213,7 +1250,7 @@ export const PullRequestSection: React.FC<{ } finally { setIsMerging(false); } - }, [directory, github, mergeMethod, refresh]); + }, [directory, github, mergeMethod, refresh, scheduleActionRefresh]); const markReady = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prReady) { @@ -1225,6 +1262,7 @@ export const PullRequestSection: React.FC<{ await github.prReady({ directory, number: pr.number }); toast.success('Marked ready for review'); await refresh({ force: true }); + scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to mark ready', { description: message }); @@ -1234,7 +1272,7 @@ export const PullRequestSection: React.FC<{ } finally { setIsMarkingReady(false); } - }, [directory, github, refresh]); + }, [directory, github, refresh, scheduleActionRefresh]); const updatePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prUpdate) { @@ -1268,13 +1306,14 @@ export const PullRequestSection: React.FC<{ setIsEditingPr(false); toast.success('PR updated'); await refresh({ force: true }); + scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to update PR', { description: message }); } finally { setIsUpdating(false); } - }, [directory, editBody, editTitle, github, prStatusKey, refresh, updatePrStatus]); + }, [directory, editBody, editTitle, github, prStatusKey, refresh, scheduleActionRefresh, updatePrStatus]); if (!canShow) { return null; diff --git a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts index 5299a5f6..a61fd9a7 100644 --- a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts +++ b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts @@ -8,8 +8,10 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionStore } from '@/stores/useSessionStore'; const MAX_BACKGROUND_PR_DIRECTORIES = 50; -const BRANCH_REFRESH_TTL_MS = 2 * 60_000; -const BRANCH_REFRESH_INTERVAL_MS = 60_000; +const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000; +const BACKGROUND_DIRECTORY_REFRESH_TTL_MS = 2 * 60_000; +const BRANCH_REFRESH_INTERVAL_MS = 15_000; +const PR_EVENTUAL_CONSISTENCY_REFRESH_DELAY_MS = 5_000; const normalizePath = (value?: string | null): string | null => { if (typeof value !== 'string') { @@ -33,9 +35,49 @@ type SessionLike = Session & { type BranchCacheEntry = { branch: string | null; + tracking: string | null; + ahead: number; + behind: number; fetchedAt: number; }; +type PrTarget = { + directory: string; + branch: string; + remoteName?: string | null; +}; + +const getBranchRefreshTtl = (directory: string, currentDirectory: string | null): number => { + return directory === currentDirectory ? ACTIVE_DIRECTORY_REFRESH_TTL_MS : BACKGROUND_DIRECTORY_REFRESH_TTL_MS; +}; + +const hasRepoSignalChanged = (previous: BranchCacheEntry | undefined, next: BranchCacheEntry): boolean => { + if (!previous) { + return Boolean(next.branch); + } + + return previous.branch !== next.branch + || previous.tracking !== next.tracking + || previous.ahead !== next.ahead + || previous.behind !== next.behind; +}; + +const toPrTargets = (cache: Map, directories: string[]): PrTarget[] => { + const result: PrTarget[] = []; + directories.forEach((directory) => { + const cached = cache.get(directory); + if (!cached?.branch) { + return; + } + result.push({ + directory, + branch: cached.branch, + remoteName: null, + }); + }); + return result; +}; + export const useGitHubPrBackgroundTracking = ( github: RuntimeAPIs['github'] | undefined, git: RuntimeAPIs['git'], @@ -52,14 +94,47 @@ export const useGitHubPrBackgroundTracking = ( const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const syncBackgroundTargets = useGitHubPrStatusStore((state) => state.syncBackgroundTargets); + const refreshPrTargets = useGitHubPrStatusStore((state) => state.refreshTargets); const [branchCache, setBranchCache] = React.useState>(new Map()); const branchCacheRef = React.useRef>(new Map()); + const targetsRef = React.useRef([]); + const burstTimeoutsRef = React.useRef>(new Map()); React.useEffect(() => { branchCacheRef.current = branchCache; }, [branchCache]); + const scheduleBurstRefresh = React.useCallback((targetsToRefresh: PrTarget[]) => { + if (targetsToRefresh.length === 0) { + return; + } + + const dedupedTargets = new Map(); + targetsToRefresh.forEach((target) => { + const key = `${target.directory}::${target.branch}`; + dedupedTargets.set(key, target); + }); + + dedupedTargets.forEach((target, key) => { + const existing = burstTimeoutsRef.current.get(key); + if (typeof existing === 'number') { + window.clearTimeout(existing); + } + + const timeoutId = window.setTimeout(() => { + burstTimeoutsRef.current.delete(key); + void refreshPrTargets([target], { + force: true, + silent: true, + markInitialResolved: true, + }); + }, PR_EVENTUAL_CONSISTENCY_REFRESH_DELAY_MS); + + burstTimeoutsRef.current.set(key, timeoutId); + }); + }, [refreshPrTargets]); + React.useEffect(() => { if (!github || githubAuthChecked) { return; @@ -104,7 +179,7 @@ export const useGitHubPrBackgroundTracking = ( React.useEffect(() => { let cancelled = false; - const refreshBranches = async (force = false) => { + const refreshBranches = async (force = false): Promise => { const now = Date.now(); const directoriesToFetch = candidateDirectories.filter((directory) => { const cached = branchCacheRef.current.get(directory); @@ -114,11 +189,11 @@ export const useGitHubPrBackgroundTracking = ( if (force) { return true; } - return now - cached.fetchedAt > BRANCH_REFRESH_TTL_MS; + return now - cached.fetchedAt > getBranchRefreshTtl(directory, currentDirectory); }); if (directoriesToFetch.length === 0) { - return; + return toPrTargets(branchCacheRef.current, candidateDirectories); } const results = await Promise.all( @@ -126,38 +201,84 @@ export const useGitHubPrBackgroundTracking = ( try { const status = await git.getGitStatus(directory); const branch = typeof status.current === 'string' ? status.current.trim() : ''; - return { directory, branch: branch && branch !== 'HEAD' ? branch : null }; + return { + directory, + branch: branch && branch !== 'HEAD' ? branch : null, + tracking: typeof status.tracking === 'string' ? status.tracking : null, + ahead: typeof status.ahead === 'number' ? status.ahead : 0, + behind: typeof status.behind === 'number' ? status.behind : 0, + }; } catch { - return { directory, branch: null }; + return { directory, branch: null, tracking: null, ahead: 0, behind: 0 }; } }), ); if (cancelled) { - return; + return []; } - setBranchCache((prev) => { - const next = new Map(prev); - let changed = false; - results.forEach(({ directory, branch }) => { - const previous = next.get(directory); - const fetchedAt = Date.now(); - if (!previous || previous.branch !== branch) { - changed = true; - } - if (!previous || previous.fetchedAt !== fetchedAt || previous.branch !== branch) { - next.set(directory, { branch, fetchedAt }); - } - }); + const nextCache = new Map(branchCacheRef.current); + const changedTargets: PrTarget[] = []; - if (!changed && results.length > 0) { + results.forEach(({ directory, branch, tracking, ahead, behind }) => { + const previous = nextCache.get(directory); + const nextEntry = { + branch, + tracking, + ahead, + behind, + fetchedAt: Date.now(), + }; + + nextCache.set(directory, nextEntry); + + if (branch && hasRepoSignalChanged(previous, nextEntry)) { + changedTargets.push({ + directory, + branch, + remoteName: null, + }); + } + }); + + setBranchCache((prev) => { + let changed = false; + if (prev.size !== nextCache.size) { + changed = true; + } else { + for (const [key, value] of nextCache.entries()) { + const previous = prev.get(key); + if (!previous + || previous.branch !== value.branch + || previous.tracking !== value.tracking + || previous.ahead !== value.ahead + || previous.behind !== value.behind) { + changed = true; + break; + } + } + } + + branchCacheRef.current = nextCache; + + if (!changed) { return prev; } - branchCacheRef.current = next; - return next; + return nextCache; }); + + if (changedTargets.length > 0) { + void refreshPrTargets(changedTargets, { + force: true, + silent: true, + markInitialResolved: true, + }); + scheduleBurstRefresh(changedTargets); + } + + return toPrTargets(nextCache, candidateDirectories); }; void refreshBranches(); @@ -169,11 +290,48 @@ export const useGitHubPrBackgroundTracking = ( void refreshBranches(); }, BRANCH_REFRESH_INTERVAL_MS); + const refreshOnResume = () => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + + void refreshBranches(true).then((nextTargets) => { + const currentTargets = nextTargets.length > 0 ? nextTargets : targetsRef.current; + if (currentTargets.length === 0) { + return; + } + + const activeTargets = currentDirectory + ? currentTargets.filter((target) => target.directory === currentDirectory) + : []; + + if (activeTargets.length > 0) { + void refreshPrTargets(activeTargets, { + force: true, + silent: true, + markInitialResolved: true, + }); + } + + void refreshPrTargets(currentTargets, { + force: true, + onlyExistingPr: true, + silent: true, + markInitialResolved: true, + }); + }); + }; + + window.addEventListener('focus', refreshOnResume); + document.addEventListener('visibilitychange', refreshOnResume); + return () => { cancelled = true; window.clearInterval(intervalId); + window.removeEventListener('focus', refreshOnResume); + document.removeEventListener('visibilitychange', refreshOnResume); }; - }, [candidateDirectories, git]); + }, [candidateDirectories, currentDirectory, git, refreshPrTargets, scheduleBurstRefresh]); React.useEffect(() => { const validDirectories = new Set(candidateDirectories); @@ -196,21 +354,23 @@ export const useGitHubPrBackgroundTracking = ( }, [candidateDirectories]); const targets = React.useMemo(() => { - const result: Array<{ directory: string; branch: string; remoteName?: string | null }> = []; - candidateDirectories.forEach((directory) => { - const cached = branchCache.get(directory); - if (!cached?.branch) { - return; - } - result.push({ - directory, - branch: cached.branch, - remoteName: null, - }); - }); - return result; + return toPrTargets(branchCache, candidateDirectories); }, [branchCache, candidateDirectories]); + React.useEffect(() => { + targetsRef.current = targets; + }, [targets]); + + React.useEffect(() => { + const burstTimeouts = burstTimeoutsRef.current; + return () => { + burstTimeouts.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + burstTimeouts.clear(); + }; + }, []); + React.useEffect(() => { syncBackgroundTargets({ targets, diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 35fa4357..8fd483b8 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -774,6 +774,8 @@ export type GitHubPullRequestStatus = { pr?: GitHubPullRequest | null; checks?: GitHubChecksSummary | null; canMerge?: boolean; + defaultBranch?: string | null; + resolvedRemoteName?: string | null; }; export type GitHubPullRequestCreateInput = { diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 28815b91..e8ab20d4 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; +import { getSafeStorage } from './utils/safeStorage'; const PR_REVALIDATE_TTL_MS = 90_000; const PR_REVALIDATE_INTERVAL_MS = 15_000; @@ -8,6 +10,8 @@ const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const; const PR_OPEN_BUSY_INTERVAL_MS = 60_000; const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000; const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000; +const PR_PERSIST_TTL_MS = 12 * 60 * 60_000; +const PR_STATUS_STORAGE_KEY = 'openchamber.github-pr-status'; const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged'; const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => { @@ -46,6 +50,12 @@ type PrRuntimeParams = { githubConnected: boolean | null; }; +type PrEntryIdentity = { + directory: string; + branch: string; + remoteName: string | null; +}; + type PrStatusEntry = { status: GitHubPullRequestStatus | null; isLoading: boolean; @@ -55,8 +65,15 @@ type PrStatusEntry = { lastDiscoveryPollAt: number; watchers: number; params: PrRuntimeParams | null; + identity: PrEntryIdentity | null; + resolvedRemoteName: string | null; }; +type PersistedPrStatusEntry = Pick< + PrStatusEntry, + 'status' | 'isInitialStatusResolved' | 'lastRefreshAt' | 'lastDiscoveryPollAt' | 'identity' | 'resolvedRemoteName' +>; + type GitHubPrStatusStore = { entries: Record; activeRequestCount: number; @@ -66,6 +83,7 @@ type GitHubPrStatusStore = { startWatching: (key: string) => void; stopWatching: (key: string) => void; refresh: (key: string, options?: RefreshOptions) => Promise; + refreshTargets: (targets: PrTrackingTarget[], options?: RefreshOptions) => Promise; updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void; syncBackgroundTargets: (args: { targets: PrTrackingTarget[]; @@ -81,19 +99,79 @@ const inFlightBySignature = new Set(); const lastRefreshBySignature = new Map(); const backgroundWatchingKeys = new Set(); -const getSignatureFromParams = (params: PrRuntimeParams | null | undefined): string | null => { - if (!params?.directory || !params.branch) { +const createEntry = (): PrStatusEntry => ({ + status: null, + isLoading: false, + error: null, + isInitialStatusResolved: false, + lastRefreshAt: 0, + lastDiscoveryPollAt: 0, + watchers: 0, + params: null, + identity: null, + resolvedRemoteName: null, +}); + +const getIdentityFromEntry = (entry: PrStatusEntry | null | undefined): PrEntryIdentity | null => { + if (entry?.params?.directory && entry.params.branch) { + return { + directory: entry.params.directory, + branch: entry.params.branch, + remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null, + }; + } + if (!entry?.identity?.directory || !entry.identity.branch) { return null; } - return `${params.directory}::${params.branch}`; + return entry.identity; +}; + +const getSignatureFromEntry = (entry: PrStatusEntry | null | undefined): string | null => { + const identity = getIdentityFromEntry(entry); + if (!identity?.directory || !identity.branch) { + return null; + } + return `${identity.directory}::${identity.branch}`; }; const getKeysBySignature = (entries: Record, signature: string): string[] => { return Object.entries(entries) - .filter(([, entry]) => getSignatureFromParams(entry.params) === signature) + .filter(([, entry]) => getSignatureFromEntry(entry) === signature) .map(([key]) => key); }; +const mergeParams = (entry: PrStatusEntry, next: PrRuntimeParams): PrStatusEntry => { + const remoteName = next.remoteName ?? entry.params?.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null; + return { + ...entry, + params: entry.params + ? { + ...entry.params, + ...next, + remoteName, + } + : { + ...next, + remoteName, + }, + identity: { + directory: next.directory, + branch: next.branch, + remoteName, + }, + }; +}; + +const getFetchableParams = (entry: PrStatusEntry | null | undefined): PrRuntimeParams | null => { + if (!entry?.params?.canShow || !entry.params.github?.prStatus) { + return null; + } + return { + ...entry.params, + remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null, + }; +}; + const pickFetchParamsForSignature = ( entries: Record, signature: string, @@ -101,25 +179,25 @@ const pickFetchParamsForSignature = ( ): PrRuntimeParams | null => { const keys = getKeysBySignature(entries, signature); const candidates = keys - .map((key) => entries[key]) - .filter((entry): entry is PrStatusEntry => Boolean(entry?.params)) - .map((entry) => entry.params) - .filter((params): params is PrRuntimeParams => Boolean(params?.canShow && params.github?.prStatus)); + .map((key) => getFetchableParams(entries[key])) + .filter((params): params is PrRuntimeParams => Boolean(params)); if (candidates.length === 0) { return null; } - const preferred = entries[preferredKey]?.params; - if ( - preferred - && getSignatureFromParams(preferred) === signature - && preferred.canShow - && preferred.github?.prStatus - ) { + const preferred = getFetchableParams(entries[preferredKey]); + if (preferred && getSignatureFromEntry(entries[preferredKey]) === signature) { return preferred; } + const withResolvedRemote = keys + .map((key) => entries[key]) + .find((entry) => Boolean(entry?.resolvedRemoteName && getFetchableParams(entry))); + if (withResolvedRemote) { + return getFetchableParams(withResolvedRemote); + } + const withRemote = candidates.find((params) => Boolean(params.remoteName)); if (withRemote) { return withRemote; @@ -128,435 +206,489 @@ const pickFetchParamsForSignature = ( return candidates[0] ?? null; }; -const createEntry = (): PrStatusEntry => ({ - status: null, - isLoading: false, - error: null, - isInitialStatusResolved: false, - lastRefreshAt: 0, - lastDiscoveryPollAt: 0, - watchers: 0, - params: null, +const toPersistedEntry = (entry: PrStatusEntry): PersistedPrStatusEntry => ({ + status: entry.status, + isInitialStatusResolved: entry.isInitialStatusResolved, + lastRefreshAt: entry.lastRefreshAt, + lastDiscoveryPollAt: entry.lastDiscoveryPollAt, + identity: getIdentityFromEntry(entry), + resolvedRemoteName: entry.resolvedRemoteName ?? entry.status?.resolvedRemoteName ?? null, }); -const mergeParams = (current: PrRuntimeParams | null, next: PrRuntimeParams): PrRuntimeParams => { - if (!current) { - return next; - } +const hydrateEntry = (entry: PersistedPrStatusEntry | undefined): PrStatusEntry => ({ + ...createEntry(), + status: entry?.status ?? null, + isInitialStatusResolved: entry?.isInitialStatusResolved ?? false, + lastRefreshAt: entry?.lastRefreshAt ?? 0, + lastDiscoveryPollAt: entry?.lastDiscoveryPollAt ?? 0, + identity: entry?.identity ?? null, + resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null, +}); - return { - ...current, - ...next, - remoteName: next.remoteName ?? current.remoteName ?? null, - }; -}; +export const useGitHubPrStatusStore = create()( + persist( + (set, get) => ({ + entries: {}, + activeRequestCount: 0, + totalRequestCount: 0, -export const useGitHubPrStatusStore = create((set, get) => ({ - entries: {}, - activeRequestCount: 0, - totalRequestCount: 0, - - ensureEntry: (key) => { - set((state) => { - if (state.entries[key]) { - return state; - } - return { - entries: { - ...state.entries, - [key]: createEntry(), - }, - }; - }); - }, - - setParams: (key, params) => { - set((state) => { - const current = state.entries[key] ?? createEntry(); - return { - entries: { - ...state.entries, - [key]: { - ...current, - params: mergeParams(current.params, params), - }, - }, - }; - }); - }, - - startWatching: (key) => { - set((state) => { - const current = state.entries[key] ?? createEntry(); - return { - entries: { - ...state.entries, - [key]: { - ...current, - watchers: current.watchers + 1, - }, - }, - }; - }); - - if (timers.has(key)) { - return; - } - - const runBootstrapRefresh = (delayMs: number) => { - const timerId = window.setTimeout(() => { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return; - } - const entry = get().entries[key]; - if (!entry || entry.watchers <= 0) { - return; - } - if (entry.status?.pr) { - return; - } - void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); - }, delayMs); - const existing = bootstrapTimers.get(key) ?? []; - existing.push(timerId); - bootstrapTimers.set(key, existing); - }; - - void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); - PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay)); - - const timerId = window.setInterval(() => { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return; - } - - const entry = get().entries[key]; - if (!entry || entry.watchers <= 0) { - return; - } - - const hasPr = Boolean(entry.status?.pr); - if (!hasPr) { - const now = Date.now(); - if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) { - return; - } + ensureEntry: (key) => { set((state) => { - const current = state.entries[key]; - if (!current) { + if (state.entries[key]) { return state; } + return { + entries: { + ...state.entries, + [key]: createEntry(), + }, + }; + }); + }, + + setParams: (key, params) => { + set((state) => { + const current = state.entries[key] ?? createEntry(); + return { + entries: { + ...state.entries, + [key]: mergeParams(current, params), + }, + }; + }); + }, + + startWatching: (key) => { + set((state) => { + const current = state.entries[key] ?? createEntry(); return { entries: { ...state.entries, [key]: { ...current, - lastDiscoveryPollAt: now, + watchers: current.watchers + 1, }, }, }; }); - void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); - return; - } - if (isTerminalPrState(entry.status?.pr?.state)) { - return; - } - - const elapsed = Date.now() - entry.lastRefreshAt; - const nextInterval = isPendingChecks(entry.status) - ? PR_OPEN_BUSY_INTERVAL_MS - : (entry.status?.checks && entry.status.checks.state !== 'pending' - ? PR_OPEN_STABLE_INTERVAL_MS - : PR_OPEN_DEFAULT_INTERVAL_MS); - if (elapsed < nextInterval) { - return; - } - - void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true }); - }, PR_REVALIDATE_INTERVAL_MS); - - timers.set(key, timerId); - }, - - stopWatching: (key) => { - set((state) => { - const current = state.entries[key]; - if (!current) { - return state; - } - - const watchers = Math.max(0, current.watchers - 1); - return { - entries: { - ...state.entries, - [key]: { - ...current, - watchers, - }, - }, - }; - }); - - const entry = get().entries[key]; - if (entry && entry.watchers > 0) { - return; - } - - const timerId = timers.get(key); - if (typeof timerId === 'number') { - window.clearInterval(timerId); - } - timers.delete(key); - - const pendingBootstrapTimers = bootstrapTimers.get(key); - if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) { - pendingBootstrapTimers.forEach((id) => { - window.clearTimeout(id); - }); - } - bootstrapTimers.delete(key); - }, - - refresh: async (key, options) => { - const state = get(); - const entry = state.entries[key]; - const signature = getSignatureFromParams(entry?.params); - - if (!entry || !signature) { - return; - } - const signatureKeys = getKeysBySignature(state.entries, signature); - const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr)); - if (options?.onlyExistingPr && !hasExistingPr) { - return; - } - const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0; - if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) { - return; - } - if (inFlightBySignature.has(signature)) { - return; - } - - const params = pickFetchParamsForSignature(state.entries, signature, key); - if (!params) { - return; - } - - inFlightBySignature.add(signature); - lastRefreshBySignature.set(signature, Date.now()); - - set((prev) => { - const nextEntries = { ...prev.entries }; - signatureKeys.forEach((signatureKey) => { - const current = nextEntries[signatureKey]; - if (!current) { + if (timers.has(key)) { return; } - nextEntries[signatureKey] = { - ...current, - lastRefreshAt: Date.now(), - isLoading: options?.silent ? current.isLoading : true, - error: null, - }; - }); - return { - entries: nextEntries, - }; - }); - if (params.githubAuthChecked && params.githubConnected === false) { - set((prev) => { - const nextEntries = { ...prev.entries }; - signatureKeys.forEach((signatureKey) => { - const current = nextEntries[signatureKey]; - if (!current) { - return; - } - nextEntries[signatureKey] = { - ...current, - status: { connected: false }, - error: null, - isLoading: options?.silent ? current.isLoading : false, - isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, - }; - }); - return { - entries: nextEntries, - }; - }); - inFlightBySignature.delete(signature); - return; - } - - if (!params.github?.prStatus) { - set((prev) => { - const nextEntries = { ...prev.entries }; - signatureKeys.forEach((signatureKey) => { - const current = nextEntries[signatureKey]; - if (!current) { - return; - } - nextEntries[signatureKey] = { - ...current, - status: null, - error: 'GitHub runtime API unavailable', - isLoading: options?.silent ? current.isLoading : false, - isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, - }; - }); - return { - entries: nextEntries, - }; - }); - inFlightBySignature.delete(signature); - return; - } - - try { - set((prev) => ({ - ...prev, - activeRequestCount: prev.activeRequestCount + 1, - totalRequestCount: prev.totalRequestCount + 1, - })); - const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined); - set((prev) => { - const nextEntries = { ...prev.entries }; - signatureKeys.forEach((signatureKey) => { - const current = nextEntries[signatureKey]; - if (!current) { - return; - } - - const prevPr = current.status?.pr; - const nextPr = next.pr; - const shouldCarryBody = Boolean( - nextPr - && prevPr - && nextPr.number === prevPr.number - && (!nextPr.body || !nextPr.body.trim()) - && typeof prevPr.body === 'string' - && prevPr.body.trim().length > 0, - ); - - const status = shouldCarryBody && nextPr && prevPr?.body - ? { - ...next, - pr: { - ...nextPr, - body: prevPr.body, - }, + const runBootstrapRefresh = (delayMs: number) => { + const timerId = window.setTimeout(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; } - : next; - - nextEntries[signatureKey] = { - ...current, - status, - error: null, - isLoading: options?.silent ? current.isLoading : false, - isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, - }; - }); - - return { - entries: nextEntries, + const entry = get().entries[key]; + if (!entry || entry.watchers <= 0) { + return; + } + if (entry.status?.pr) { + return; + } + void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); + }, delayMs); + const existing = bootstrapTimers.get(key) ?? []; + existing.push(timerId); + bootstrapTimers.set(key, existing); }; - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - set((prev) => { - const nextEntries = { ...prev.entries }; - signatureKeys.forEach((signatureKey) => { - const current = nextEntries[signatureKey]; - if (!current) { + + void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); + PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay)); + + const timerId = window.setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { return; } - nextEntries[signatureKey] = { - ...current, - error: message || 'Failed to load PR status', - isLoading: options?.silent ? current.isLoading : false, - isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + + const entry = get().entries[key]; + if (!entry || entry.watchers <= 0) { + return; + } + + const hasPr = Boolean(entry.status?.pr); + if (!hasPr) { + const now = Date.now(); + if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) { + return; + } + set((state) => { + const current = state.entries[key]; + if (!current) { + return state; + } + return { + entries: { + ...state.entries, + [key]: { + ...current, + lastDiscoveryPollAt: now, + }, + }, + }; + }); + void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); + return; + } + + if (isTerminalPrState(entry.status?.pr?.state)) { + return; + } + + const elapsed = Date.now() - entry.lastRefreshAt; + const nextInterval = isPendingChecks(entry.status) + ? PR_OPEN_BUSY_INTERVAL_MS + : (entry.status?.checks && entry.status.checks.state !== 'pending' + ? PR_OPEN_STABLE_INTERVAL_MS + : PR_OPEN_DEFAULT_INTERVAL_MS); + if (elapsed < nextInterval) { + return; + } + + void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true }); + }, PR_REVALIDATE_INTERVAL_MS); + + timers.set(key, timerId); + }, + + stopWatching: (key) => { + set((state) => { + const current = state.entries[key]; + if (!current) { + return state; + } + + const watchers = Math.max(0, current.watchers - 1); + return { + entries: { + ...state.entries, + [key]: { + ...current, + watchers, + }, + }, }; }); - return { - entries: nextEntries, - }; - }); - } finally { - inFlightBySignature.delete(signature); - set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) })); - } - }, - updateStatus: (key, updater) => { - set((state) => { - const current = state.entries[key] ?? createEntry(); - return { - entries: { - ...state.entries, - [key]: { - ...current, - status: updater(current.status), - }, - }, - }; - }); - }, + const entry = get().entries[key]; + if (entry && entry.watchers > 0) { + return; + } - syncBackgroundTargets: ({ targets, github, githubAuthChecked, githubConnected }) => { - if (!github || targets.length === 0) { - Array.from(backgroundWatchingKeys).forEach((key) => { - get().stopWatching(key); - backgroundWatchingKeys.delete(key); - }); - return; - } + const timerId = timers.get(key); + if (typeof timerId === 'number') { + window.clearInterval(timerId); + } + timers.delete(key); - const uniqueTargets = new Map(); - targets.forEach((target) => { - const directory = target.directory.trim(); - const branch = target.branch.trim(); - if (!directory || !branch) { - return; - } - const key = getGitHubPrStatusKey(directory, branch, target.remoteName ?? null); - if (!uniqueTargets.has(key)) { - uniqueTargets.set(key, { - directory, - branch, - remoteName: target.remoteName ?? null, + const pendingBootstrapTimers = bootstrapTimers.get(key); + if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) { + pendingBootstrapTimers.forEach((id) => { + window.clearTimeout(id); + }); + } + bootstrapTimers.delete(key); + }, + + refresh: async (key, options) => { + const state = get(); + const entry = state.entries[key]; + const signature = getSignatureFromEntry(entry); + + if (!entry || !signature) { + return; + } + const signatureKeys = getKeysBySignature(state.entries, signature); + const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr)); + if (options?.onlyExistingPr && !hasExistingPr) { + return; + } + const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0; + if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) { + return; + } + if (inFlightBySignature.has(signature)) { + return; + } + + const params = pickFetchParamsForSignature(state.entries, signature, key); + if (!params) { + return; + } + + inFlightBySignature.add(signature); + lastRefreshBySignature.set(signature, Date.now()); + + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + lastRefreshAt: Date.now(), + isLoading: options?.silent ? current.isLoading : true, + error: null, + }; + }); + return { + entries: nextEntries, + }; }); - } - }); - const nextKeys = new Set(uniqueTargets.keys()); + if (params.githubAuthChecked && params.githubConnected === false) { + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + status: { connected: false }, + error: null, + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + return { + entries: nextEntries, + }; + }); + inFlightBySignature.delete(signature); + return; + } - Array.from(backgroundWatchingKeys).forEach((key) => { - if (nextKeys.has(key)) { - return; - } - get().stopWatching(key); - backgroundWatchingKeys.delete(key); - }); + if (!params.github?.prStatus) { + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + status: null, + error: 'GitHub runtime API unavailable', + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + return { + entries: nextEntries, + }; + }); + inFlightBySignature.delete(signature); + return; + } - uniqueTargets.forEach((target, key) => { - get().ensureEntry(key); - get().setParams(key, { - directory: target.directory, - branch: target.branch, - remoteName: target.remoteName ?? null, - canShow: true, - github, - githubAuthChecked, - githubConnected, - }); + try { + set((prev) => ({ + ...prev, + activeRequestCount: prev.activeRequestCount + 1, + totalRequestCount: prev.totalRequestCount + 1, + })); + const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined); + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } - if (!backgroundWatchingKeys.has(key)) { - get().startWatching(key); - backgroundWatchingKeys.add(key); - } - }); - }, -})); + const prevPr = current.status?.pr; + const nextPr = next.pr; + const shouldCarryBody = Boolean( + nextPr + && prevPr + && nextPr.number === prevPr.number + && (!nextPr.body || !nextPr.body.trim()) + && typeof prevPr.body === 'string' + && prevPr.body.trim().length > 0, + ); + + const status = shouldCarryBody && nextPr && prevPr?.body + ? { + ...next, + pr: { + ...nextPr, + body: prevPr.body, + }, + } + : next; + + const resolvedRemoteName = status.resolvedRemoteName ?? current.resolvedRemoteName ?? params.remoteName ?? null; + const identity = getIdentityFromEntry(current) ?? { + directory: params.directory, + branch: params.branch, + remoteName: params.remoteName ?? null, + }; + + nextEntries[signatureKey] = { + ...current, + status, + error: null, + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + resolvedRemoteName, + identity: { + ...identity, + remoteName: resolvedRemoteName ?? identity.remoteName ?? null, + }, + }; + }); + + return { + entries: nextEntries, + }; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + error: message || 'Failed to load PR status', + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + return { + entries: nextEntries, + }; + }); + } finally { + inFlightBySignature.delete(signature); + set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) })); + } + }, + + refreshTargets: async (targets, options) => { + const keys = Array.from(new Set( + targets + .map((target) => { + const directory = target.directory.trim(); + const branch = target.branch.trim(); + if (!directory || !branch) { + return null; + } + return getGitHubPrStatusKey(directory, branch, target.remoteName ?? null); + }) + .filter((key): key is string => Boolean(key)), + )); + + await Promise.all(keys.map((key) => get().refresh(key, options))); + }, + + updateStatus: (key, updater) => { + set((state) => { + const current = state.entries[key] ?? createEntry(); + return { + entries: { + ...state.entries, + [key]: { + ...current, + status: updater(current.status), + }, + }, + }; + }); + }, + + syncBackgroundTargets: ({ targets, github, githubAuthChecked, githubConnected }) => { + if (!github || targets.length === 0) { + Array.from(backgroundWatchingKeys).forEach((key) => { + get().stopWatching(key); + backgroundWatchingKeys.delete(key); + }); + return; + } + + const uniqueTargets = new Map(); + targets.forEach((target) => { + const directory = target.directory.trim(); + const branch = target.branch.trim(); + if (!directory || !branch) { + return; + } + const key = getGitHubPrStatusKey(directory, branch, target.remoteName ?? null); + if (!uniqueTargets.has(key)) { + uniqueTargets.set(key, { + directory, + branch, + remoteName: target.remoteName ?? null, + }); + } + }); + + const nextKeys = new Set(uniqueTargets.keys()); + + Array.from(backgroundWatchingKeys).forEach((key) => { + if (nextKeys.has(key)) { + return; + } + get().stopWatching(key); + backgroundWatchingKeys.delete(key); + }); + + uniqueTargets.forEach((target, key) => { + get().ensureEntry(key); + get().setParams(key, { + directory: target.directory, + branch: target.branch, + remoteName: target.remoteName ?? null, + canShow: true, + github, + githubAuthChecked, + githubConnected, + }); + + if (!backgroundWatchingKeys.has(key)) { + get().startWatching(key); + backgroundWatchingKeys.add(key); + } + }); + }, + }), + { + name: PR_STATUS_STORAGE_KEY, + storage: createJSONStorage(() => getSafeStorage()), + partialize: (state) => ({ + entries: Object.fromEntries( + Object.entries(state.entries) + .filter(([, entry]) => { + const identity = getIdentityFromEntry(entry); + if (!identity?.directory || !identity.branch) { + return false; + } + const freshness = Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt); + return freshness === 0 || Date.now() - freshness < PR_PERSIST_TTL_MS; + }) + .map(([key, entry]) => [key, toPersistedEntry(entry)]), + ), + }), + merge: (persistedState, currentState) => { + const persistedEntries = (persistedState as { entries?: Record } | undefined)?.entries ?? {}; + const current = currentState as GitHubPrStatusStore; + return { + ...current, + entries: Object.fromEntries( + Object.entries(persistedEntries).map(([key, entry]) => [key, hydrateEntry(entry)]), + ), + }; + }, + }, + ), +); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index b9613908..63e9b2d1 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -9579,6 +9579,9 @@ async function main(options = {}) { }; }; + const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403; + const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404; + app.get('/api/github/auth/status', async (_req, res) => { try { const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); @@ -9597,7 +9600,7 @@ async function main(options = {}) { try { user = await getGitHubUserSummary(octokit); } catch (error) { - if (error?.status === 401) { + if (isGitHubAuthInvalid(error)) { clearGitHubAuth(); return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); } @@ -9733,7 +9736,7 @@ async function main(options = {}) { try { user = await getGitHubUserSummary(octokit); } catch (error) { - if (error?.status === 401) { + if (isGitHubAuthInvalid(error)) { clearGitHubAuth(); return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); } @@ -9773,7 +9776,7 @@ async function main(options = {}) { try { user = await getGitHubUserSummary(octokit); } catch (error) { - if (error?.status === 401) { + if (isGitHubAuthInvalid(error)) { clearGitHubAuth(); return res.status(401).json({ error: 'GitHub token expired or revoked' }); } @@ -9803,94 +9806,20 @@ async function main(options = {}) { return res.json({ connected: false }); } - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory, remote); - if (!repo) { - return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }); + const { resolveGitHubPrStatus } = await import('./lib/github/pr-status.js'); + const resolvedStatus = await resolveGitHubPrStatus({ + octokit, + directory, + branch, + remoteName: remote, + }); + const searchRepo = resolvedStatus.repo; + const first = resolvedStatus.pr; + if (!searchRepo) { + return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null }); } - - let originRepo = null; - if (remote !== 'origin') { - const originResolved = await resolveGitHubRepoFromDirectory(directory, 'origin').catch(() => ({ repo: null })); - originRepo = originResolved?.repo || null; - } - - const candidateHeadOwners = []; - const pushHeadOwner = (owner) => { - if (typeof owner !== 'string') return; - const normalized = owner.trim(); - if (!normalized) return; - if (candidateHeadOwners.includes(normalized)) return; - candidateHeadOwners.push(normalized); - }; - - // First, use branch tracking remote owner (where branch is usually pushed). - const { getStatus } = await import('./lib/git/index.js'); - const status = await getStatus(directory).catch(() => null); - if (status?.tracking) { - const trackingRemote = status.tracking.split('/')[0]; - if (trackingRemote) { - const trackingResolved = await resolveGitHubRepoFromDirectory(directory, trackingRemote).catch(() => ({ repo: null })); - pushHeadOwner(trackingResolved?.repo?.owner); - } - } - - // Then same-repo and origin fallback owners. - pushHeadOwner(repo.owner); - pushHeadOwner(originRepo?.owner); - - const listByHead = async (targetRepo, state, headOwner) => { - const resp = await octokit.rest.pulls.list({ - owner: targetRepo.owner, - repo: targetRepo.repo, - state, - head: `${headOwner}:${branch}`, - per_page: 10, - }); - return Array.isArray(resp?.data) ? resp.data[0] : null; - }; - - const listByHeadRef = async (targetRepo, state) => { - const resp = await octokit.rest.pulls.list({ - owner: targetRepo.owner, - repo: targetRepo.repo, - state, - per_page: 100, - }); - const matches = Array.isArray(resp?.data) - ? resp.data.filter((pr) => pr?.head?.ref === branch) - : []; - return matches[0] ?? null; - }; - - const tryFindPr = async (targetRepo) => { - let found = null; - for (const owner of candidateHeadOwners) { - found = await listByHead(targetRepo, 'open', owner); - if (found) return found; - found = await listByHead(targetRepo, 'closed', owner); - if (found) return found; - } - found = await listByHeadRef(targetRepo, 'open'); - if (found) return found; - return listByHeadRef(targetRepo, 'closed'); - }; - - // Try requested remote target repo first, then origin target repo fallback for fork flows. - let searchRepo = repo; - let first = await tryFindPr(searchRepo); - if (!first && originRepo) { - const isDifferentRepo = originRepo.owner !== repo.owner || originRepo.repo !== repo.repo; - if (isDifferentRepo) { - const originMatch = await tryFindPr(originRepo); - if (originMatch) { - first = originMatch; - searchRepo = originRepo; - } - } - } if (!first) { - return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null }); } // Enrich with mergeability fields @@ -10006,6 +9935,8 @@ async function main(options = {}) { }, checks, canMerge, + defaultBranch: resolvedStatus.defaultBranch ?? null, + resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null, }); } catch (error) { if (error?.status === 401) { @@ -10013,6 +9944,18 @@ async function main(options = {}) { clearGitHubAuth(); return res.json({ connected: false }); } + if (isGitHubResourceUnavailable(error)) { + return res.json({ + connected: true, + repo: null, + branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '', + pr: null, + checks: null, + canMerge: false, + defaultBranch: null, + resolvedRemoteName: null, + }); + } console.error('Failed to load GitHub PR status:', error); return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' }); } diff --git a/packages/web/server/lib/github/DOCUMENTATION.md b/packages/web/server/lib/github/DOCUMENTATION.md index f98b7eca..0248d410 100644 --- a/packages/web/server/lib/github/DOCUMENTATION.md +++ b/packages/web/server/lib/github/DOCUMENTATION.md @@ -1,47 +1,170 @@ # GitHub Module Documentation ## Purpose -This module provides GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing utilities for the web server runtime. + +- This module owns GitHub auth, Octokit access, repo resolution, and Pull Request status resolution for OpenChamber. +- From user perspective, this is the layer that lets the app know which PR belongs to a local branch and keeps that UI feeling current. ## Entrypoints and structure -- `packages/web/server/lib/github/index.js`: public entrypoint imported by `packages/web/server/index.js`. -- `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, and client ID/scope configuration. -- `packages/web/server/lib/github/device-flow.js`: OAuth device code flow implementation for browserless auth. -- `packages/web/server/lib/github/octokit.js`: Octokit client factory backed by current auth. -- `packages/web/server/lib/github/repo/index.js`: GitHub remote URL parser and directory-to-repo resolver. -## Public exports (from index.js) +- `packages/web/server/lib/github/index.js`: public server entrypoint. +- `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config. +- `packages/web/server/lib/github/device-flow.js`: OAuth device flow. +- `packages/web/server/lib/github/octokit.js`: Octokit factory for the current auth. +- `packages/web/server/lib/github/repo/index.js`: remote URL parsing and directory-to-repo resolution. +- `packages/web/server/lib/github/pr-status.js`: PR lookup across remotes, forks, and upstreams. +- `packages/web/server/index.js`: API route layer that calls this module. +- `packages/web/src/api/github.ts`: web client wrapper for GitHub endpoints. -### Auth (`auth.js`) -- `getGitHubAuth()`: Returns current auth entry (accessToken, user, scope, accountId). -- `getGitHubAuthAccounts()`: Returns list of all configured accounts. -- `setGitHubAuth({ accessToken, scope, tokenType, user, accountId })`: Stores or updates auth entry. -- `activateGitHubAuth(accountId)`: Sets specified account as current. -- `clearGitHubAuth()`: Removes current account or deletes storage file if last account. -- `getGitHubClientId()`: Resolves client ID from env var, settings.json, or default. -- `getGitHubScopes()`: Resolves scopes from env var, settings.json, or default. -- `GITHUB_AUTH_FILE`: Storage file path constant. +## Public exports -### Device flow (`device-flow.js`) -- `startDeviceFlow({ clientId, scope })`: Requests device code from GitHub. -- `exchangeDeviceCode({ clientId, deviceCode })`: Polls for access token. +### Auth -### Octokit (`octokit.js`) -- `getOctokitOrNull()`: Returns configured Octokit instance or null if no auth. +- `getGitHubAuth()`: current auth entry. +- `getGitHubAuthAccounts()`: all configured accounts. +- `setGitHubAuth({ accessToken, scope, tokenType, user, accountId })`: save or update account. +- `activateGitHubAuth(accountId)`: switch active account. +- `clearGitHubAuth()`: clear current account. +- `getGitHubClientId()`: resolve client id. +- `getGitHubScopes()`: resolve scopes. +- `GITHUB_AUTH_FILE`: auth file path. -### Repo (`repo/index.js`) -- `parseGitHubRemoteUrl(raw)`: Parses SSH/HTTPS URLs into `{ owner, repo, url }`. -- `resolveGitHubRepoFromDirectory(directory, remoteName)`: Resolves GitHub repo from git remote. +### Device flow -## Storage and configuration -- Auth storage: `~/.config/openchamber/github-auth.json` (atomic writes, mode 0o600). -- Client ID: `OPENCHAMBER_GITHUB_CLIENT_ID` env var → `settings.json` → default. -- Scopes: `OPENCHAMBER_GITHUB_SCOPES` env var → `settings.json` → default. +- `startDeviceFlow({ clientId, scope })`: request device code. +- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token. -## Account resolution -Account IDs are resolved in priority order: explicit `accountId` → user login → user ID → token prefix. +### Octokit + +- `getOctokitOrNull()`: current Octokit or `null`. + +### Repo + +- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`. +- `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote. + +## Auth storage and config + +- Auth storage: `~/.config/openchamber/github-auth.json` +- Writes are atomic and file mode is `0o600`. +- Client ID resolution order: `OPENCHAMBER_GITHUB_CLIENT_ID` -> `settings.json` -> default. +- Scope resolution order: `OPENCHAMBER_GITHUB_SCOPES` -> `settings.json` -> default. +- Account id resolution order: explicit `accountId` -> user login -> user id -> token prefix. + +## PR integration overview + +- The UI asks `github.prStatus(directory, branch, remote?)` from `packages/web/src/api/github.ts`. +- That hits `GET /api/github/pr/status` in `packages/web/server/index.js`. +- The route calls `resolveGitHubPrStatus(...)` in `packages/web/server/lib/github/pr-status.js`. +- The resolver finds the most likely repo and PR for a local branch. +- The route then enriches that result with checks, mergeability, and permission-related fields. +- The client caches and shares the result between sidebar and Git view. + +## Consumers of PR data + +- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`. +- `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` renders the compact badge, PR number, title, checks summary, and GitHub link. +- `packages/ui/src/components/views/git/PullRequestSection.tsx` uses the same shared entry for the full PR workflow. +- `packages/ui/src/components/ui/MemoryDebugPanel.tsx` reads request counters for debugging. + +## How PR resolution works + +- It reads local git status and remotes first. +- It ranks remotes in this order: explicit remote, tracking remote, `origin`, `upstream`, then the rest. +- It resolves those remotes into GitHub repos. +- It expands each repo through `parent` and `source` so PRs in upstream repos can still be found. +- It skips PR lookup when the current branch matches that repo's default branch. +- It first searches for PRs by likely source owner plus exact head branch. +- If that fails, it falls back to broader GitHub search for the branch name. +- `403` and `404` during repo lookups are treated as expected gaps, not hard errors. + +## Shared client state model + +- Client key is effectively `directory::branch`. +- One entry stores last known status, loading state, error, timestamps, watcher count, identity, and resolved remote. +- Requests are deduplicated by branch signature, not by component instance. +- This keeps sidebar and Git view aligned and avoids duplicated fetches. + +## Persistence + +- PR state is persisted in local storage under `openchamber.github-pr-status`. +- Persisted fields include status, timestamps, identity, and resolved remote. +- Runtime-only details are not persisted. +- Persisted entries expire after 12 hours. +- On reload, users get last known state first, then background refresh resumes. + +## Polling and refresh model + +- There are two layers: entry-level polling in `useGitHubPrStatusStore` and repo scanning in `useGitHubPrBackgroundTracking`. +- Entry-level polling decides when a known branch should revalidate PR state. +- Background tracking decides which directories and branches should even be watched. + +## Entry-level polling rules + +- Start watching -> immediate refresh. +- If no PR is found yet -> retry after `2s` and `5s`. +- Still no PR -> discovery refresh every `5m`. +- Open PR with pending checks -> refresh about every `1m`. +- Open PR with non-pending checks -> refresh about every `5m`. +- Open PR without a stable checks signal -> refresh about every `2m`. +- Closed or merged PR -> stop regular polling. +- Hidden tab -> skip polling. +- Non-forced refreshes use a `90s` TTL. + +## Background tracking rules + +- Track up to `50` likely directories. +- Sources are current directory, projects, worktrees, active sessions, and archived sessions. +- Active directory branch TTL is `15s`. +- Background directory branch TTL is `2m`. +- Background scan wakes every `15s`, but only fetches directories whose TTL expired. +- Each scan reads `branch`, `tracking`, `ahead`, and `behind` from git status. +- If any of those branch signals change, that branch's PR status refreshes immediately. +- After that, one more delayed refresh runs after `5s` to catch GitHub eventual consistency. + +## UI refresh triggers + +- App or tab becomes visible. +- Window regains focus. +- Current branch changes. +- Tracking branch changes. +- Ahead or behind changes. +- User selects a different remote in Git view. +- GitHub auth state changes. + +## Action-based refreshes in Git view + +- After `Create PR` -> refresh now, then after `2s` and `5s`. +- After `Merge PR` -> refresh now, then after `2s` and `5s`. +- After `Mark ready for review` -> refresh now, then after `2s` and `5s`. +- After `Update PR` -> refresh now, then after `2s` and `5s`. + +## Sidebar behavior + +- Sidebar shows only compact PR state. +- Aggregation is by `directory::branch`, so multiple sessions on one branch share one signal. +- If multiple entries exist, sidebar keeps the strongest visible PR state. +- Visual state is based on PR health, not merge permissions. + +## Git view behavior + +- Git view watches one branch directly. +- It supports create, edit, mark ready, and merge. +- It can probe alternate remotes so fork-heavy setups still find the right PR. +- It uses the same shared store as the sidebar. + +## Failure handling + +- If GitHub is disconnected, API returns `connected: false`. +- If a repo is private or inaccessible, resolver calls may quietly return no PR. +- Sidebar stays quiet on missing or inaccessible PR state. +- Git view is where explicit PR-level problems should be shown. ## Notes for contributors -- All auth operations use atomic file writes for safe multi-instance sharing. -- Device flow handles GitHub's `authorization_pending` responses at caller level. -- Repo parser supports `git@github.com:`, `ssh://git@github.com/`, and `https://github.com/` URL formats. + +- Keep the UI calm. Do not add noisy diagnostics to the sidebar. +- Prefer shared state over per-component fetches. +- Prefer event-shaped refreshes over blind frequent polling. +- Prefer correctness for fork and multi-remote setups over assuming `origin` is enough. +- Device flow handles GitHub `authorization_pending` at caller level. +- Repo parser supports `git@github.com:`, `ssh://git@github.com/`, and `https://github.com/`. diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js new file mode 100644 index 00000000..7af68a32 --- /dev/null +++ b/packages/web/server/lib/github/pr-status.js @@ -0,0 +1,478 @@ +import { getRemotes, getStatus } from '../git/index.js'; +import { resolveGitHubRepoFromDirectory } from './repo/index.js'; + +const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000; +const defaultBranchCache = new Map(); +const repoMetadataCache = new Map(); + +const normalizeText = (value) => typeof value === 'string' ? value.trim() : ''; +const normalizeLower = (value) => normalizeText(value).toLowerCase(); +const normalizeRepoKey = (owner, repo) => { + const normalizedOwner = normalizeLower(owner); + const normalizedRepo = normalizeLower(repo); + if (!normalizedOwner || !normalizedRepo) { + return ''; + } + return `${normalizedOwner}/${normalizedRepo}`; +}; +const parseTrackingRemoteName = (trackingBranch) => { + const normalized = normalizeText(trackingBranch); + if (!normalized) { + return ''; + } + const slashIndex = normalized.indexOf('/'); + if (slashIndex <= 0) { + return ''; + } + return normalized.slice(0, slashIndex).trim(); +}; + +const pushUnique = (collection, value, keyFn = normalizeLower) => { + const normalizedValue = normalizeText(value); + if (!normalizedValue) { + return; + } + const nextKey = keyFn(normalizedValue); + if (!nextKey) { + return; + } + if (collection.some((item) => keyFn(item) === nextKey)) { + return; + } + collection.push(normalizedValue); +}; + +const rankRemoteNames = (remoteNames, explicitRemoteName, trackingRemoteName) => { + const ranked = []; + pushUnique(ranked, explicitRemoteName); + + if (trackingRemoteName) { + pushUnique(ranked, trackingRemoteName); + } + + pushUnique(ranked, 'origin'); + pushUnique(ranked, 'upstream'); + remoteNames.forEach((name) => pushUnique(ranked, name)); + return ranked; +}; + +const getHeadOwner = (pr) => { + const repoOwner = normalizeText(pr?.head?.repo?.owner?.login); + if (repoOwner) { + return repoOwner; + } + const userOwner = normalizeText(pr?.head?.user?.login); + if (userOwner) { + return userOwner; + } + const headLabel = normalizeText(pr?.head?.label); + const separatorIndex = headLabel.indexOf(':'); + if (separatorIndex > 0) { + return headLabel.slice(0, separatorIndex).trim(); + } + return ''; +}; + +const getHeadRepoKey = (pr, fallbackRepoName) => { + const repoOwner = normalizeText(pr?.head?.repo?.owner?.login); + const repoName = normalizeText(pr?.head?.repo?.name); + if (repoOwner && repoName) { + return normalizeRepoKey(repoOwner, repoName); + } + const headLabel = normalizeText(pr?.head?.label); + const separatorIndex = headLabel.indexOf(':'); + if (separatorIndex > 0) { + const labelOwner = headLabel.slice(0, separatorIndex).trim(); + if (labelOwner && fallbackRepoName) { + return normalizeRepoKey(labelOwner, fallbackRepoName); + } + } + return ''; +}; + +const buildSourceMatcher = (sourceCandidates) => { + const repoRank = new Map(); + const ownerRank = new Map(); + + sourceCandidates.forEach((candidate, index) => { + const repoKey = normalizeRepoKey(candidate.repo?.owner, candidate.repo?.repo); + if (repoKey && !repoRank.has(repoKey)) { + repoRank.set(repoKey, index); + } + const owner = normalizeLower(candidate.repo?.owner); + if (owner && !ownerRank.has(owner)) { + ownerRank.set(owner, index); + } + }); + + const matches = (pr, fallbackRepoName) => { + const repoKey = getHeadRepoKey(pr, fallbackRepoName); + if (repoKey && repoRank.has(repoKey)) { + return true; + } + const owner = normalizeLower(getHeadOwner(pr)); + return Boolean(owner) && ownerRank.has(owner); + }; + + const compare = (left, right, fallbackRepoName) => { + const leftRepoRank = repoRank.get(getHeadRepoKey(left, fallbackRepoName)); + const rightRepoRank = repoRank.get(getHeadRepoKey(right, fallbackRepoName)); + const leftRepoScore = typeof leftRepoRank === 'number' ? leftRepoRank : Number.POSITIVE_INFINITY; + const rightRepoScore = typeof rightRepoRank === 'number' ? rightRepoRank : Number.POSITIVE_INFINITY; + if (leftRepoScore !== rightRepoScore) { + return leftRepoScore - rightRepoScore; + } + + const leftOwnerRank = ownerRank.get(normalizeLower(getHeadOwner(left))); + const rightOwnerRank = ownerRank.get(normalizeLower(getHeadOwner(right))); + const leftOwnerScore = typeof leftOwnerRank === 'number' ? leftOwnerRank : Number.POSITIVE_INFINITY; + const rightOwnerScore = typeof rightOwnerRank === 'number' ? rightOwnerRank : Number.POSITIVE_INFINITY; + if (leftOwnerScore !== rightOwnerScore) { + return leftOwnerScore - rightOwnerScore; + } + + return 0; + }; + + return { matches, compare }; +}; + +const getRepoDefaultBranch = async (octokit, repo) => { + const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); + if (!repoKey) { + return null; + } + + const cached = defaultBranchCache.get(repoKey); + if (cached && Date.now() - cached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) { + return cached.defaultBranch; + } + + try { + const response = await octokit.rest.repos.get({ + owner: repo.owner, + repo: repo.repo, + }); + const defaultBranch = normalizeText(response?.data?.default_branch) || null; + defaultBranchCache.set(repoKey, { + defaultBranch, + fetchedAt: Date.now(), + }); + return defaultBranch; + } catch { + return null; + } +}; + +const getRepoMetadata = async (octokit, repo) => { + const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); + if (!repoKey) { + return null; + } + + const cached = repoMetadataCache.get(repoKey); + if (cached && Date.now() - cached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) { + return cached.data; + } + + try { + const response = await octokit.rest.repos.get({ + owner: repo.owner, + repo: repo.repo, + }); + const data = response?.data ?? null; + repoMetadataCache.set(repoKey, { + data, + fetchedAt: Date.now(), + }); + return data; + } catch (error) { + if (error?.status === 403 || error?.status === 404) { + repoMetadataCache.set(repoKey, { + data: null, + fetchedAt: Date.now(), + }); + return null; + } + throw error; + } +}; + +const resolveRemoteCandidates = async (directory, rankedRemoteNames) => { + const results = []; + const seenRepoKeys = new Set(); + + for (const remoteName of rankedRemoteNames) { + const resolved = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null })); + const repo = resolved?.repo || null; + const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); + if (!repo || !repoKey || seenRepoKeys.has(repoKey)) { + continue; + } + seenRepoKeys.add(repoKey); + results.push({ + remoteName, + repo, + }); + } + + return results; +}; + +const expandRepoNetwork = async (octokit, candidates) => { + const expanded = []; + const seenRepoKeys = new Set(); + + const pushCandidate = (repo, remoteName, priority) => { + const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); + if (!repoKey || seenRepoKeys.has(repoKey)) { + return; + } + seenRepoKeys.add(repoKey); + expanded.push({ repo, remoteName, priority }); + }; + + for (const candidate of candidates) { + const metadata = await getRepoMetadata(octokit, candidate.repo); + if (!metadata) { + continue; + } + + pushCandidate(candidate.repo, candidate.remoteName, candidate.priority); + + const parent = metadata?.parent; + if (parent?.owner?.login && parent?.name) { + pushCandidate({ + owner: parent.owner.login, + repo: parent.name, + url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`, + }, candidate.remoteName, candidate.priority + 0.1); + } + + const source = metadata?.source; + if (source?.owner?.login && source?.name) { + pushCandidate({ + owner: source.owner.login, + repo: source.name, + url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`, + }, candidate.remoteName, candidate.priority + 0.2); + } + } + + return expanded.sort((left, right) => left.priority - right.priority); +}; + +const safeListPulls = async (octokit, options) => { + try { + const response = await octokit.rest.pulls.list(options); + return Array.isArray(response?.data) ? response.data : []; + } catch (error) { + if (error?.status === 404 || error?.status === 403) { + return []; + } + throw error; + } +}; + +const parseRepoFromApiUrl = (value) => { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + try { + const url = new URL(normalized); + const parts = url.pathname.replace(/^\/+/, '').split('/').filter(Boolean); + if (parts.length < 2 || parts[0] !== 'repos') { + return null; + } + const owner = parts[1]; + const repo = parts[2]; + if (!owner || !repo) { + return null; + } + return { owner, repo }; + } catch { + return null; + } +}; + +const searchFallbackPr = async ({ octokit, branch, repoNames }) => { + const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean)); + + for (const state of ['open', 'closed']) { + let response; + try { + response = await octokit.rest.search.issuesAndPullRequests({ + q: `is:pr state:${state} head:${branch}`, + per_page: 20, + }); + } catch (error) { + if (error?.status === 403 || error?.status === 404) { + continue; + } + throw error; + } + + const items = Array.isArray(response?.data?.items) ? response.data.items : []; + for (const item of items) { + const repo = parseRepoFromApiUrl(item?.repository_url); + if (!repo) { + continue; + } + if (normalizedRepoNames.size > 0 && !normalizedRepoNames.has(normalizeLower(repo.repo))) { + continue; + } + try { + const prResponse = await octokit.rest.pulls.get({ + owner: repo.owner, + repo: repo.repo, + pull_number: item.number, + }); + const pr = prResponse?.data; + if (!pr || normalizeText(pr.head?.ref) !== branch) { + continue; + } + return { + repo: { + owner: repo.owner, + repo: repo.repo, + url: `https://github.com/${repo.owner}/${repo.repo}`, + }, + pr, + }; + } catch (error) { + if (error?.status === 403 || error?.status === 404) { + continue; + } + throw error; + } + } + } + + return null; +}; + +const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }) => { + const matcher = buildSourceMatcher(sourceCandidates); + const sourceOwners = []; + sourceCandidates.forEach((candidate) => pushUnique(sourceOwners, candidate.repo?.owner)); + + const pickPreferred = (prs) => prs + .filter((pr) => normalizeText(pr?.head?.ref) === branch) + .filter((pr) => matcher.matches(pr, target.repo.repo)) + .sort((left, right) => matcher.compare(left, right, target.repo.repo))[0] ?? null; + + for (const state of ['open', 'closed']) { + for (const owner of sourceOwners) { + const directCandidates = await safeListPulls(octokit, { + owner: target.repo.owner, + repo: target.repo.repo, + state, + head: `${owner}:${branch}`, + per_page: 100, + }); + const direct = pickPreferred(directCandidates); + if (direct) { + return direct; + } + } + + const fallbackCandidates = await safeListPulls(octokit, { + owner: target.repo.owner, + repo: target.repo.repo, + state, + per_page: 100, + }); + const fallback = pickPreferred(fallbackCandidates); + if (fallback) { + return fallback; + } + } + + return null; +}; + +export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) { + const normalizedBranch = normalizeText(branch); + const normalizedRemoteName = normalizeText(remoteName) || 'origin'; + + const [status, remotes] = await Promise.all([ + getStatus(directory).catch(() => null), + getRemotes(directory).catch(() => []), + ]); + + const trackingRemoteName = parseTrackingRemoteName(status?.tracking); + const rankedRemoteNames = rankRemoteNames( + Array.isArray(remotes) ? remotes.map((remote) => remote?.name).filter(Boolean) : [], + normalizedRemoteName, + trackingRemoteName, + ); + + const resolvedRemoteTargets = await resolveRemoteCandidates(directory, rankedRemoteNames.slice(0, 3)); + const resolvedTargets = await expandRepoNetwork( + octokit, + resolvedRemoteTargets.map((target, index) => ({ ...target, priority: index })), + ); + if (resolvedTargets.length === 0) { + return { + repo: null, + pr: null, + defaultBranch: null, + resolvedRemoteName: null, + }; + } + + const sourceCandidates = resolvedTargets.slice(); + + let fallbackRepo = resolvedTargets[0].repo; + let fallbackRemoteName = resolvedTargets[0].remoteName; + let fallbackDefaultBranch = await getRepoDefaultBranch(octokit, fallbackRepo); + + for (const target of resolvedTargets) { + const defaultBranch = await getRepoDefaultBranch(octokit, target.repo); + if (!fallbackRepo) { + fallbackRepo = target.repo; + fallbackRemoteName = target.remoteName; + fallbackDefaultBranch = defaultBranch; + } + if (defaultBranch && defaultBranch === normalizedBranch) { + continue; + } + + const pr = await findFirstMatchingPr({ + octokit, + target, + branch: normalizedBranch, + sourceCandidates, + }); + if (pr) { + return { + repo: target.repo, + pr, + defaultBranch, + resolvedRemoteName: target.remoteName, + }; + } + } + + const fallbackSearch = await searchFallbackPr({ + octokit, + branch: normalizedBranch, + repoNames: resolvedTargets.map((target) => target.repo.repo), + }); + if (fallbackSearch) { + return { + repo: fallbackSearch.repo, + pr: fallbackSearch.pr, + defaultBranch: await getRepoDefaultBranch(octokit, fallbackSearch.repo), + resolvedRemoteName: null, + }; + } + + return { + repo: fallbackRepo, + pr: null, + defaultBranch: fallbackDefaultBranch, + resolvedRemoteName: fallbackRemoteName, + }; +}