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, + }; +}