fix(github): keep merged PRs as branch history instead of hiding them
Branch status resolves an open PR across the whole fork network first, so a merged fork PR can never hide an open upstream PR for the same head. Only when no target has an open PR does the branch's newest closed/merged PR come back, as history. The panel shows that history as a compact note and offers creating the next PR below it, instead of either sticking on a terminal PR or going blank after a merge. Terminal associations stay persisted for reload continuity but are never treated as authority: they revalidate on the discovery cadence and on focus. History is looked up only for the branch's own remote and name, and remembered per repo+branch, so the extra lookup cannot exhaust the route's resolve budget. The checks summary and merge-permission lookup are skipped for a closed or merged PR, where neither is actionable.
This commit is contained in:
@@ -508,8 +508,13 @@ export const PullRequestSection: React.FC<{
|
||||
}, [useDetectedUpstream, detectedUpstream?.defaultBranch]);
|
||||
|
||||
const pr = status?.pr ?? null;
|
||||
// A closed/merged PR is the branch's history, not its live status: it still
|
||||
// deserves to be shown (you just merged it), but the branch is free again, so
|
||||
// the panel offers creating the next PR instead of a read-only detail view.
|
||||
const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed';
|
||||
const livePr = isHistoricalPr ? null : pr;
|
||||
|
||||
const prContextKey = pr ? getPrContextKey(directory, pr.number) : null;
|
||||
const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null;
|
||||
const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined));
|
||||
const ensurePrContext = usePrContextStore((state) => state.ensure);
|
||||
const prContext = prContextEntry?.result ?? null;
|
||||
@@ -525,14 +530,14 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
// Load the context the active segment needs; checks include details.
|
||||
React.useEffect(() => {
|
||||
if (!pr || !github?.prContext || activeSegment === 'overview') {
|
||||
if (!livePr || !github?.prContext || activeSegment === 'overview') {
|
||||
return;
|
||||
}
|
||||
void ensurePrContext(github, directory, pr.number, {
|
||||
void ensurePrContext(github, directory, livePr.number, {
|
||||
includeCheckDetails: activeSegment === 'checks',
|
||||
sourceRepo: status?.repo ?? null,
|
||||
});
|
||||
}, [activeSegment, directory, ensurePrContext, github, pr, status?.repo]);
|
||||
}, [activeSegment, directory, ensurePrContext, github, livePr, status?.repo]);
|
||||
|
||||
const checks = status?.checks ?? null;
|
||||
const checksArePending = (checks?.pending ?? 0) > 0;
|
||||
@@ -1167,12 +1172,11 @@ export const PullRequestSection: React.FC<{
|
||||
}, [remotes, status?.resolvedRemoteName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Terminal (closed/merged) status must still revalidate on focus/visibility:
|
||||
// the branch may now have a newer open PR, or the association may clear.
|
||||
// Recompute staleness inside the handlers — a captured boolean freezes after
|
||||
// the first fresh refresh until lastRefreshAt changes again.
|
||||
const onFocus = () => {
|
||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||
// Coming back to the app is the moment a PR is most likely to have changed
|
||||
// elsewhere — including a merged one being replaced by a newer open PR — so
|
||||
// staleness is read from the store when the event fires, not captured here.
|
||||
const refreshWhenStale = () => {
|
||||
const lastRefreshAt = useGitHubPrStatusStore.getState().entries[prStatusKey]?.lastRefreshAt ?? 0;
|
||||
if (Date.now() - lastRefreshAt > 60_000) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
@@ -1181,19 +1185,16 @@ export const PullRequestSection: React.FC<{
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||
if (Date.now() - lastRefreshAt > 60_000) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
refreshWhenStale();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('focus', refreshWhenStale);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('focus', refreshWhenStale);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [refresh, statusEntry?.lastRefreshAt]);
|
||||
}, [prStatusKey, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
@@ -1614,7 +1615,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
{t('gitView.pr.checkingStatus')}
|
||||
</div>
|
||||
) : pr ? (
|
||||
) : pr && !isHistoricalPr ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="h-8 min-w-0">
|
||||
<SortableTabsStrip
|
||||
@@ -1967,6 +1968,30 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{pr && isHistoricalPr ? (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-surface-muted/40 px-2.5 py-2">
|
||||
<Icon
|
||||
name={pr.state === 'merged' ? 'git-merge' : 'git-close-pull-request'}
|
||||
className="size-4 shrink-0"
|
||||
style={{ color: prColorVar }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 typography-micro text-muted-foreground">
|
||||
{pr.state === 'merged'
|
||||
? t('gitView.pr.history.merged', { number: pr.number, base: pr.base || targetBaseBranch })
|
||||
: t('gitView.pr.history.closed', { number: pr.number })}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
|
||||
|
||||
@@ -933,6 +933,8 @@ export const dict = {
|
||||
'gitView.pr.field.draft': 'Entwurf',
|
||||
'gitView.pr.field.title': 'Titel',
|
||||
'gitView.pr.githubNotConnected': 'GitHub ist nicht verbunden',
|
||||
'gitView.pr.history.merged': 'PR #{number} wurde in {base} gemergt.',
|
||||
'gitView.pr.history.closed': 'PR #{number} wurde geschlossen.',
|
||||
'gitView.pr.loadingDescription': 'Beschreibung wird geladen...',
|
||||
'gitView.pr.mergeMethod.merge': 'Einen Merge-Commit erstellen',
|
||||
'gitView.pr.mergeMethod.rebase': 'Rebase und Merge',
|
||||
|
||||
@@ -997,6 +997,8 @@ export const dict = {
|
||||
'gitView.pr.field.draft': 'Draft',
|
||||
'gitView.pr.field.title': 'Title',
|
||||
'gitView.pr.githubNotConnected': 'GitHub is not connected',
|
||||
'gitView.pr.history.merged': 'PR #{number} was merged into {base}.',
|
||||
'gitView.pr.history.closed': 'PR #{number} was closed.',
|
||||
'gitView.pr.loadingDescription': 'Loading description...',
|
||||
'gitView.pr.mergeMethod.merge': 'Create a merge commit',
|
||||
'gitView.pr.mergeMethod.rebase': 'Rebase and merge',
|
||||
|
||||
@@ -998,6 +998,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.pr.field.draft": "Borrador",
|
||||
"gitView.pr.field.title": "Título",
|
||||
"gitView.pr.githubNotConnected": "GitHub no está conectado",
|
||||
"gitView.pr.history.merged": "La PR #{number} se fusionó en {base}.",
|
||||
"gitView.pr.history.closed": "La PR #{number} se cerró.",
|
||||
"gitView.pr.loadingDescription": "Cargando descripción...",
|
||||
"gitView.pr.mergeMethod.merge": "Crear un merge commit",
|
||||
"gitView.pr.mergeMethod.rebase": "Rebase y merge",
|
||||
|
||||
@@ -817,6 +817,8 @@ export const dict = {
|
||||
'gitView.pr.field.draft': 'Brouillon',
|
||||
'gitView.pr.field.title': 'Titre',
|
||||
'gitView.pr.githubNotConnected': 'GitHub n\'est pas connecté',
|
||||
'gitView.pr.history.merged': 'La PR #{number} a été fusionnée dans {base}.',
|
||||
'gitView.pr.history.closed': 'La PR #{number} a été fermée.',
|
||||
'gitView.pr.loadingDescription': 'Chargement de la description de la PR...',
|
||||
'gitView.pr.mergeMethod.merge': 'Créer un commit de fusion',
|
||||
'gitView.pr.mergeMethod.rebase': 'Rebase et fusionner',
|
||||
|
||||
@@ -994,6 +994,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.pr.field.draft': '下書き',
|
||||
'gitView.pr.field.title': 'タイトル',
|
||||
'gitView.pr.githubNotConnected': 'GitHubが接続されていません',
|
||||
'gitView.pr.history.merged': 'PR #{number} は {base} にマージされました。',
|
||||
'gitView.pr.history.closed': 'PR #{number} はクローズされました。',
|
||||
'gitView.pr.loadingDescription': '説明を読み込み中...',
|
||||
'gitView.pr.mergeMethod.merge': 'マージコミットを作成',
|
||||
'gitView.pr.mergeMethod.rebase': 'リベースしてマージ',
|
||||
|
||||
@@ -998,6 +998,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.pr.field.draft': '드래프트',
|
||||
'gitView.pr.field.title': '제목',
|
||||
'gitView.pr.githubNotConnected': 'GitHub에 연결되지 않음',
|
||||
'gitView.pr.history.merged': 'PR #{number}이(가) {base}에 병합되었습니다.',
|
||||
'gitView.pr.history.closed': 'PR #{number}이(가) 닫혔습니다.',
|
||||
'gitView.pr.loadingDescription': '설명 로드 중…',
|
||||
'gitView.pr.mergeMethod.merge': '병합 커밋 생성',
|
||||
'gitView.pr.mergeMethod.rebase': '리베이스 후 병합',
|
||||
|
||||
@@ -2187,6 +2187,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.pr.field.draft': 'Szkic',
|
||||
'gitView.pr.field.title': 'Tytuł',
|
||||
'gitView.pr.githubNotConnected': 'GitHub nie jest połączony',
|
||||
'gitView.pr.history.merged': 'PR #{number} został scalony do {base}.',
|
||||
'gitView.pr.history.closed': 'PR #{number} został zamknięty.',
|
||||
'gitView.pr.loadingDescription': 'Loading description...',
|
||||
'gitView.pr.mergeMethod.merge': 'Create a merge commit',
|
||||
'gitView.pr.mergeMethod.rebase': 'Rebase and merge',
|
||||
|
||||
@@ -998,6 +998,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.pr.field.draft": "Borrador",
|
||||
"gitView.pr.field.title": "Título",
|
||||
"gitView.pr.githubNotConnected": "GitHub não está conectado",
|
||||
"gitView.pr.history.merged": "A PR #{number} foi mesclada em {base}.",
|
||||
"gitView.pr.history.closed": "A PR #{number} foi fechada.",
|
||||
"gitView.pr.loadingDescription": "Carregando descrição...",
|
||||
"gitView.pr.mergeMethod.merge": "Criar um merge commit",
|
||||
"gitView.pr.mergeMethod.rebase": "Rebase e merge",
|
||||
|
||||
@@ -998,6 +998,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.pr.field.draft": "Чернетка",
|
||||
"gitView.pr.field.title": "Назва",
|
||||
"gitView.pr.githubNotConnected": "GitHub не підключено",
|
||||
"gitView.pr.history.merged": "PR #{number} злито в {base}.",
|
||||
"gitView.pr.history.closed": "PR #{number} закрито.",
|
||||
"gitView.pr.loadingDescription": "Завантаження опису...",
|
||||
"gitView.pr.mergeMethod.merge": "Створити коміт злиття",
|
||||
"gitView.pr.mergeMethod.rebase": "Перебазувати та злити",
|
||||
|
||||
@@ -998,6 +998,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.pr.field.draft': '草稿',
|
||||
'gitView.pr.field.title': '标题',
|
||||
'gitView.pr.githubNotConnected': 'GitHub 未连接',
|
||||
'gitView.pr.history.merged': 'PR #{number} 已合并到 {base}。',
|
||||
'gitView.pr.history.closed': 'PR #{number} 已关闭。',
|
||||
'gitView.pr.loadingDescription': '正在加载描述...',
|
||||
'gitView.pr.mergeMethod.merge': '创建合并提交',
|
||||
'gitView.pr.mergeMethod.rebase': '变基并合并',
|
||||
|
||||
@@ -1010,6 +1010,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.pr.field.draft': '草稿',
|
||||
'gitView.pr.field.title': '標題',
|
||||
'gitView.pr.githubNotConnected': 'GitHub 未連線',
|
||||
'gitView.pr.history.merged': 'PR #{number} 已合併到 {base}。',
|
||||
'gitView.pr.history.closed': 'PR #{number} 已關閉。',
|
||||
'gitView.pr.loadingDescription': '正在載入描述...',
|
||||
'gitView.pr.mergeMethod.merge': '建立合併提交',
|
||||
'gitView.pr.mergeMethod.rebase': 'Rebase 並合併',
|
||||
|
||||
@@ -173,10 +173,10 @@ Important properties:
|
||||
- `refreshTargets()` supports one-shot multi-target bootstrap without turning on live watching
|
||||
- runtime reset disposes timers, watchers, API references, and request ownership while inert namespaced snapshots remain isolated
|
||||
- persisted cache is versioned, TTL-filtered, and bounded for page refresh continuity, not broad background syncing
|
||||
- closed/merged associations use the same `5m` discovery cadence as missing PRs so a newer open PR (or authoritative `pr: null`) can replace them without a manual refresh
|
||||
- closed/merged branch associations are not persisted; legacy hydrated terminal PRs are stripped to `pr: null` and marked unresolved until refresh
|
||||
- sibling remote-key seeding never copies a closed/merged association
|
||||
- a successful refresh that returns `pr: null` replaces any previously cached PR authoritatively
|
||||
- a closed/merged PR is the branch's history, not live status: it is displayed and persisted, but never treated as authority
|
||||
- closed/merged associations use the same `5m` discovery cadence as missing PRs so a newer open PR (or authoritative `pr: null`) replaces them without a manual refresh
|
||||
- hydrate restores a persisted closed/merged PR but resets its `lastDiscoveryPollAt`, so revalidation runs on the first watcher tick after a reload
|
||||
- a successful refresh that returns `pr: null` replaces any previously cached PR authoritatively; a failed refresh keeps the previous one
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr).toBeNull()
|
||||
})
|
||||
|
||||
test("does not seed sibling entries from a closed PR", () => {
|
||||
test("seeds sibling entries from a closed PR without freezing discovery", () => {
|
||||
const closed: GitHubPullRequestStatus = {
|
||||
connected: true,
|
||||
fetchedAt: 1_000,
|
||||
@@ -405,8 +405,12 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
})
|
||||
|
||||
useGitHubPrStatusStore.getState().ensureEntry(originKey)
|
||||
expect(useGitHubPrStatusStore.getState().entries[originKey]?.status).toBeNull()
|
||||
expect(useGitHubPrStatusStore.getState().entries[originKey]?.isInitialStatusResolved).toBe(false)
|
||||
const seeded = useGitHubPrStatusStore.getState().entries[originKey]
|
||||
expect(seeded?.status?.pr?.number).toBe(9)
|
||||
// Seeding is display continuity only: the seeded entry has never refreshed
|
||||
// or polled, so its own discovery still runs immediately.
|
||||
expect(seeded?.lastRefreshAt).toBe(0)
|
||||
expect(seeded?.lastDiscoveryPollAt).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps a cached PR when a forced refresh fails", async () => {
|
||||
@@ -441,7 +445,7 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
expect(useGitHubPrStatusStore.getState().entries[key]?.error).toBe("GitHub unavailable")
|
||||
})
|
||||
|
||||
test("does not persist a merged branch association", () => {
|
||||
test("persists a merged branch association as history", () => {
|
||||
const merged: GitHubPullRequestStatus = {
|
||||
connected: true,
|
||||
fetchedAt: 1_000,
|
||||
@@ -464,8 +468,8 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
|
||||
const persisted = useGitHubPrStatusStore.persist.getOptions().partialize?.(
|
||||
useGitHubPrStatusStore.getState(),
|
||||
) as { entries?: Record<string, unknown> } | undefined
|
||||
expect(persisted?.entries?.[key]).toBe(undefined)
|
||||
) as { entries?: Record<string, { status?: GitHubPullRequestStatus | null }> } | undefined
|
||||
expect(persisted?.entries?.[key]?.status?.pr?.number).toBe(12)
|
||||
})
|
||||
|
||||
test("still persists an open branch association", () => {
|
||||
@@ -495,7 +499,7 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
expect(persisted?.entries?.[key]?.status?.pr?.number).toBe(15)
|
||||
})
|
||||
|
||||
test("hydrate strips a legacy persisted merged PR and marks it unresolved", () => {
|
||||
test("hydrate keeps a persisted merged PR but forces the next discovery poll", () => {
|
||||
const key = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||
const hydrated = useGitHubPrStatusStore.persist.getOptions().merge?.(
|
||||
{
|
||||
@@ -511,7 +515,7 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
},
|
||||
isInitialStatusResolved: true,
|
||||
lastRefreshAt: Date.now(),
|
||||
lastDiscoveryPollAt: 0,
|
||||
lastDiscoveryPollAt: Date.now(),
|
||||
identity: {
|
||||
runtimeKey: "runtime-a",
|
||||
directory: "/repo",
|
||||
@@ -527,17 +531,19 @@ describe("GitHub PR status stale terminal associations", () => {
|
||||
entries: Record<string, {
|
||||
status: GitHubPullRequestStatus | null
|
||||
isInitialStatusResolved: boolean
|
||||
lastDiscoveryPollAt: number
|
||||
}>
|
||||
}
|
||||
|
||||
expect(hydrated.entries[key]?.status?.pr).toBeNull()
|
||||
expect(hydrated.entries[key]?.status?.pr?.number).toBe(12)
|
||||
expect(hydrated.entries[key]?.status?.repo).toEqual({
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
url: "https://github.com/acme/app",
|
||||
})
|
||||
expect(hydrated.entries[key]?.status?.checks).toBe(undefined)
|
||||
expect(hydrated.entries[key]?.status?.canMerge).toBe(undefined)
|
||||
expect(hydrated.entries[key]?.isInitialStatusResolved).toBe(false)
|
||||
expect(hydrated.entries[key]?.isInitialStatusResolved).toBe(true)
|
||||
// Restored history must not inherit a fresh discovery timestamp, otherwise
|
||||
// a newer open PR would wait a full discovery interval after every reload.
|
||||
expect(hydrated.entries[key]?.lastDiscoveryPollAt).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -212,11 +212,6 @@ const findResolvedSiblingEntry = (
|
||||
if (entryKey === key || !entry.isInitialStatusResolved || !entry.status) {
|
||||
continue;
|
||||
}
|
||||
// Never seed a fresh key from a closed/merged association — that is what
|
||||
// made stale terminal PRs reappear after remote-key switches.
|
||||
if (isTerminalPrState(entry.status.pr?.state)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseStatusKey(entryKey);
|
||||
if (!parsed
|
||||
|| parsed.runtimeKey !== target.runtimeKey
|
||||
@@ -364,35 +359,18 @@ const toPersistedEntry = (entry: PrStatusEntry): PersistedPrStatusEntry => ({
|
||||
resolvedRemoteName: entry.resolvedRemoteName ?? entry.status?.resolvedRemoteName ?? null,
|
||||
});
|
||||
|
||||
const stripTerminalPersistedStatus = (
|
||||
status: GitHubPullRequestStatus | null | undefined,
|
||||
): GitHubPullRequestStatus | null => {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
if (!isTerminalPrState(status.pr?.state)) {
|
||||
return status;
|
||||
}
|
||||
// Persisted closed/merged branch associations are not live authority. Keep
|
||||
// repo/remote continuity so refresh can resume without briefly showing the
|
||||
// stale terminal PR.
|
||||
return {
|
||||
...status,
|
||||
pr: null,
|
||||
checks: undefined,
|
||||
canMerge: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const hydrateEntry = (entry: PersistedPrStatusEntry | undefined): PrStatusEntry => {
|
||||
const status = stripTerminalPersistedStatus(entry?.status);
|
||||
const hadTerminalPr = Boolean(entry?.status?.pr) && !status?.pr;
|
||||
// A persisted closed/merged PR is restored so the panel keeps showing the
|
||||
// branch's PR history across a reload. It is never treated as live authority:
|
||||
// `lastDiscoveryPollAt` is reset so the watcher revalidates it immediately and
|
||||
// an open PR (or an authoritative empty result) replaces it.
|
||||
const hasTerminalPr = isTerminalPrState(entry?.status?.pr?.state);
|
||||
return {
|
||||
...createEntry(),
|
||||
status,
|
||||
isInitialStatusResolved: hadTerminalPr ? false : (entry?.isInitialStatusResolved ?? false),
|
||||
status: entry?.status ?? null,
|
||||
isInitialStatusResolved: entry?.isInitialStatusResolved ?? false,
|
||||
lastRefreshAt: entry?.lastRefreshAt ?? 0,
|
||||
lastDiscoveryPollAt: entry?.lastDiscoveryPollAt ?? 0,
|
||||
lastDiscoveryPollAt: hasTerminalPr ? 0 : (entry?.lastDiscoveryPollAt ?? 0),
|
||||
identity: entry?.identity ?? null,
|
||||
resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null,
|
||||
};
|
||||
@@ -501,8 +479,9 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
if (!entry || entry.watchers <= 0) {
|
||||
return;
|
||||
}
|
||||
// Bootstrap retries only help discovery before any PR is known. Once a
|
||||
// terminal PR is cached, the discovery interval owns revalidation.
|
||||
// Bootstrap retries only help discovery before any PR is known.
|
||||
// Once a PR is cached — open or historical — the discovery interval
|
||||
// owns revalidation.
|
||||
if (entry.status?.pr) {
|
||||
return;
|
||||
}
|
||||
@@ -527,10 +506,10 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
}
|
||||
|
||||
const hasPr = Boolean(entry.status?.pr);
|
||||
// A closed/merged PR is history, not live status. It stays on the
|
||||
// discovery cadence like a branch with no PR at all, so a newer open
|
||||
// PR — or an authoritative empty result — replaces it on its own.
|
||||
const isTerminal = isTerminalPrState(entry.status?.pr?.state);
|
||||
// Missing PR and terminal (closed/merged) PRs both need discovery:
|
||||
// a new open PR may exist for the same head, or the association may
|
||||
// need to clear to an authoritative empty result.
|
||||
if (!hasPr || isTerminal) {
|
||||
const now = Date.now();
|
||||
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
|
||||
@@ -881,11 +860,6 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
if (!identity?.directory || !identity.branch) {
|
||||
return false;
|
||||
}
|
||||
// Do not persist closed/merged branch associations — they become
|
||||
// permanently sticky without a discovery refresh after reload.
|
||||
if (isTerminalPrState(entry.status?.pr?.state)) {
|
||||
return false;
|
||||
}
|
||||
const freshness = Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt);
|
||||
return freshness > 0 && Date.now() - freshness < PR_PERSIST_TTL_MS;
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user