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:
Bohdan Triapitsyn
2026-08-15 17:56:55 +03:00
parent 52ac367b1e
commit 268f9ea9f2
21 changed files with 405 additions and 190 deletions
+4 -4
View File
@@ -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;
})