fix(github): stop stale merged PRs from sticking in branch status
Branch PR status now resolves open PRs only, revalidates closed/merged associations on a discovery cadence, and clears authoritative empty results so the panel can self-heal without a manual refresh. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
Cursor Agent
co-authored by
Serhii Dziupin
parent
6b1e677aaf
commit
962b016cbd
@@ -1167,21 +1167,19 @@ export const PullRequestSection: React.FC<{
|
|||||||
}, [remotes, status?.resolvedRemoteName]);
|
}, [remotes, status?.resolvedRemoteName]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged';
|
// Terminal (closed/merged) status must still revalidate on focus/visibility:
|
||||||
|
// the branch may now have a newer open PR, or the association may clear.
|
||||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||||
const isStale = Date.now() - lastRefreshAt > 60_000;
|
const isStale = Date.now() - lastRefreshAt > 60_000;
|
||||||
const shouldRefresh = !isTerminal && isStale;
|
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
if (shouldRefresh) {
|
if (isStale) {
|
||||||
void refresh({ force: true, silent: true });
|
void refresh({ force: true, silent: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onVisibility = () => {
|
const onVisibility = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible' && isStale) {
|
||||||
if (shouldRefresh) {
|
void refresh({ force: true, silent: true });
|
||||||
void refresh({ force: true, silent: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1191,7 +1189,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
window.removeEventListener('focus', onFocus);
|
window.removeEventListener('focus', onFocus);
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
};
|
};
|
||||||
}, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]);
|
}, [refresh, statusEntry?.lastRefreshAt]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ Important properties:
|
|||||||
- `refreshTargets()` supports one-shot multi-target bootstrap without turning on live watching
|
- `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
|
- 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
|
- 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
|
||||||
|
|
||||||
## Ownership Rules
|
## Ownership Rules
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
|
import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
|
||||||
|
|
||||||
let runtimeKey = "runtime-a"
|
let runtimeKey = "runtime-a"
|
||||||
@@ -166,3 +166,246 @@ describe("GitHub PR status cache ownership", () => {
|
|||||||
expect(useGitHubPrStatusStore.getState().entries[key]?.isLoading).toBe(false)
|
expect(useGitHubPrStatusStore.getState().entries[key]?.isLoading).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("GitHub PR status stale terminal associations", () => {
|
||||||
|
const originalSetInterval = globalThis.setInterval
|
||||||
|
const originalSetTimeout = globalThis.setTimeout
|
||||||
|
const originalClearInterval = globalThis.clearInterval
|
||||||
|
const originalClearTimeout = globalThis.clearTimeout
|
||||||
|
let intervalCallbacks: Array<() => void> = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runtimeKey = "runtime-a"
|
||||||
|
intervalCallbacks = []
|
||||||
|
|
||||||
|
const setIntervalStub = ((handler: TimerHandler) => {
|
||||||
|
if (typeof handler === "function") {
|
||||||
|
intervalCallbacks.push(handler as () => void)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}) as unknown as typeof setInterval
|
||||||
|
const setTimeoutStub = (() => 1) as unknown as typeof setTimeout
|
||||||
|
const clearIntervalStub = (() => undefined) as typeof clearInterval
|
||||||
|
const clearTimeoutStub = (() => undefined) as typeof clearTimeout
|
||||||
|
|
||||||
|
globalThis.setInterval = setIntervalStub
|
||||||
|
globalThis.setTimeout = setTimeoutStub
|
||||||
|
globalThis.clearInterval = clearIntervalStub
|
||||||
|
globalThis.clearTimeout = clearTimeoutStub
|
||||||
|
|
||||||
|
// bun:test has no DOM; the store uses window timers and optional document visibility.
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: {
|
||||||
|
setInterval: setIntervalStub,
|
||||||
|
setTimeout: setTimeoutStub,
|
||||||
|
clearInterval: clearIntervalStub,
|
||||||
|
clearTimeout: clearTimeoutStub,
|
||||||
|
},
|
||||||
|
document: { visibilityState: "visible" },
|
||||||
|
})
|
||||||
|
|
||||||
|
useGitHubPrStatusStore.setState({ entries: {}, activeRequestCount: 0, totalRequestCount: 0 })
|
||||||
|
useGitHubPrStatusStore.getState().resetForRuntimeSwitch()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useGitHubPrStatusStore.getState().resetForRuntimeSwitch()
|
||||||
|
globalThis.setInterval = originalSetInterval
|
||||||
|
globalThis.setTimeout = originalSetTimeout
|
||||||
|
globalThis.clearInterval = originalClearInterval
|
||||||
|
globalThis.clearTimeout = originalClearTimeout
|
||||||
|
delete (globalThis as { window?: unknown }).window
|
||||||
|
delete (globalThis as { document?: unknown }).document
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forced refresh replaces a merged PR with a newer open PR", async () => {
|
||||||
|
const merged: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 1_000,
|
||||||
|
pr: { number: 12, title: "old", url: "u12", state: "merged", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
const newerOpen: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 2_000,
|
||||||
|
pr: { number: 15, title: "new", url: "u15", state: "open", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
let requestCount = 0
|
||||||
|
const github = {
|
||||||
|
prStatus: async () => {
|
||||||
|
requestCount += 1
|
||||||
|
return requestCount === 1 ? merged : newerOpen
|
||||||
|
},
|
||||||
|
} as unknown as RuntimeAPIs["github"]
|
||||||
|
const key = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||||
|
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||||
|
useGitHubPrStatusStore.getState().setParams(key, params(github, "feature"))
|
||||||
|
|
||||||
|
await useGitHubPrStatusStore.getState().refresh(key, { force: true })
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number).toBe(12)
|
||||||
|
|
||||||
|
await useGitHubPrStatusStore.getState().refresh(key, { force: true })
|
||||||
|
expect(requestCount).toBe(2)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number).toBe(15)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.state).toBe("open")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forced refresh clears a merged PR when no open PR remains", async () => {
|
||||||
|
const merged: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 1_000,
|
||||||
|
repo: { owner: "acme", repo: "app", url: "https://github.com/acme/app" },
|
||||||
|
pr: { number: 12, title: "old", url: "u12", state: "merged", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
const empty: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 2_000,
|
||||||
|
repo: { owner: "acme", repo: "app", url: "https://github.com/acme/app" },
|
||||||
|
pr: null,
|
||||||
|
}
|
||||||
|
const github = {
|
||||||
|
prStatus: async () => empty,
|
||||||
|
} as unknown as RuntimeAPIs["github"]
|
||||||
|
const key = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||||
|
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||||
|
useGitHubPrStatusStore.setState((state) => ({
|
||||||
|
entries: {
|
||||||
|
...state.entries,
|
||||||
|
[key]: {
|
||||||
|
...state.entries[key]!,
|
||||||
|
status: merged,
|
||||||
|
isInitialStatusResolved: true,
|
||||||
|
lastRefreshAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
useGitHubPrStatusStore.getState().setParams(key, params(github, "feature"))
|
||||||
|
|
||||||
|
await useGitHubPrStatusStore.getState().refresh(key, { force: true })
|
||||||
|
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr).toBeNull()
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.repo).toEqual({
|
||||||
|
owner: "acme",
|
||||||
|
repo: "app",
|
||||||
|
url: "https://github.com/acme/app",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("watcher discovery revalidates a cached merged PR", async () => {
|
||||||
|
const merged: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 1_000,
|
||||||
|
pr: { number: 12, title: "old", url: "u12", state: "merged", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
const newerOpen: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 2_000,
|
||||||
|
pr: { number: 15, title: "new", url: "u15", state: "open", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
const responses = [merged, newerOpen]
|
||||||
|
let requestCount = 0
|
||||||
|
const github = {
|
||||||
|
prStatus: async () => {
|
||||||
|
requestCount += 1
|
||||||
|
return responses.shift()!
|
||||||
|
},
|
||||||
|
} as unknown as RuntimeAPIs["github"]
|
||||||
|
const key = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||||
|
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||||
|
useGitHubPrStatusStore.getState().setParams(key, params(github, "feature"))
|
||||||
|
useGitHubPrStatusStore.getState().startWatching(key)
|
||||||
|
|
||||||
|
for (let i = 0; i < 50 && useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number !== 12; i += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
expect(requestCount).toBe(1)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number).toBe(12)
|
||||||
|
expect(intervalCallbacks).toHaveLength(1)
|
||||||
|
|
||||||
|
// Discovery poll for terminal state (lastDiscoveryPollAt starts at 0).
|
||||||
|
intervalCallbacks[0]!()
|
||||||
|
for (let i = 0; i < 50 && useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number !== 15; i += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestCount).toBe(2)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number).toBe(15)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.state).toBe("open")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("watcher discovery clears a cached merged PR when no open PR exists", async () => {
|
||||||
|
const merged: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 1_000,
|
||||||
|
pr: { number: 12, title: "old", url: "u12", state: "merged", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
const empty: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 2_000,
|
||||||
|
pr: null,
|
||||||
|
}
|
||||||
|
const responses = [merged, empty]
|
||||||
|
let requestCount = 0
|
||||||
|
const github = {
|
||||||
|
prStatus: async () => {
|
||||||
|
requestCount += 1
|
||||||
|
return responses.shift()!
|
||||||
|
},
|
||||||
|
} as unknown as RuntimeAPIs["github"]
|
||||||
|
const key = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||||
|
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||||
|
useGitHubPrStatusStore.getState().setParams(key, params(github, "feature"))
|
||||||
|
useGitHubPrStatusStore.getState().startWatching(key)
|
||||||
|
|
||||||
|
for (let i = 0; i < 50 && useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number !== 12; i += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
expect(requestCount).toBe(1)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr?.number).toBe(12)
|
||||||
|
|
||||||
|
intervalCallbacks[0]!()
|
||||||
|
for (let i = 0; i < 50 && useGitHubPrStatusStore.getState().entries[key]?.status?.pr != null; i += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestCount).toBe(2)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.pr).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not seed sibling entries from a closed PR", () => {
|
||||||
|
const closed: GitHubPullRequestStatus = {
|
||||||
|
connected: true,
|
||||||
|
fetchedAt: 1_000,
|
||||||
|
pr: { number: 9, title: "closed", url: "u9", state: "closed", draft: false, base: "main", head: "feature" },
|
||||||
|
}
|
||||||
|
const autoKey = getGitHubPrStatusKey("/repo", "feature", null)
|
||||||
|
const originKey = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||||
|
useGitHubPrStatusStore.setState({
|
||||||
|
entries: {
|
||||||
|
[autoKey]: {
|
||||||
|
status: closed,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isInitialStatusResolved: true,
|
||||||
|
lastRefreshAt: Date.now(),
|
||||||
|
lastDiscoveryPollAt: 0,
|
||||||
|
watchers: 0,
|
||||||
|
params: null,
|
||||||
|
identity: {
|
||||||
|
runtimeKey: "runtime-a",
|
||||||
|
directory: "/repo",
|
||||||
|
branch: "feature",
|
||||||
|
remoteName: null,
|
||||||
|
},
|
||||||
|
resolvedRemoteName: "origin",
|
||||||
|
paramsRevision: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
activeRequestCount: 0,
|
||||||
|
totalRequestCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
useGitHubPrStatusStore.getState().ensureEntry(originKey)
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[originKey]?.status).toBeNull()
|
||||||
|
expect(useGitHubPrStatusStore.getState().entries[originKey]?.isInitialStatusResolved).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ const findResolvedSiblingEntry = (
|
|||||||
if (entryKey === key || !entry.isInitialStatusResolved || !entry.status) {
|
if (entryKey === key || !entry.isInitialStatusResolved || !entry.status) {
|
||||||
continue;
|
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);
|
const parsed = parseStatusKey(entryKey);
|
||||||
if (!parsed
|
if (!parsed
|
||||||
|| parsed.runtimeKey !== target.runtimeKey
|
|| parsed.runtimeKey !== target.runtimeKey
|
||||||
@@ -359,15 +364,39 @@ const toPersistedEntry = (entry: PrStatusEntry): PersistedPrStatusEntry => ({
|
|||||||
resolvedRemoteName: entry.resolvedRemoteName ?? entry.status?.resolvedRemoteName ?? null,
|
resolvedRemoteName: entry.resolvedRemoteName ?? entry.status?.resolvedRemoteName ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hydrateEntry = (entry: PersistedPrStatusEntry | undefined): PrStatusEntry => ({
|
const stripTerminalPersistedStatus = (
|
||||||
...createEntry(),
|
status: GitHubPullRequestStatus | null | undefined,
|
||||||
status: entry?.status ?? null,
|
): GitHubPullRequestStatus | null => {
|
||||||
isInitialStatusResolved: entry?.isInitialStatusResolved ?? false,
|
if (!status) {
|
||||||
lastRefreshAt: entry?.lastRefreshAt ?? 0,
|
return null;
|
||||||
lastDiscoveryPollAt: entry?.lastDiscoveryPollAt ?? 0,
|
}
|
||||||
identity: entry?.identity ?? null,
|
if (!isTerminalPrState(status.pr?.state)) {
|
||||||
resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null,
|
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;
|
||||||
|
return {
|
||||||
|
...createEntry(),
|
||||||
|
status,
|
||||||
|
isInitialStatusResolved: hadTerminalPr ? false : (entry?.isInitialStatusResolved ?? false),
|
||||||
|
lastRefreshAt: entry?.lastRefreshAt ?? 0,
|
||||||
|
lastDiscoveryPollAt: entry?.lastDiscoveryPollAt ?? 0,
|
||||||
|
identity: entry?.identity ?? null,
|
||||||
|
resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const boundEntries = (entries: Record<string, PrStatusEntry>): Record<string, PrStatusEntry> => {
|
const boundEntries = (entries: Record<string, PrStatusEntry>): Record<string, PrStatusEntry> => {
|
||||||
const all = Object.entries(entries);
|
const all = Object.entries(entries);
|
||||||
@@ -496,7 +525,11 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPr = Boolean(entry.status?.pr);
|
const hasPr = Boolean(entry.status?.pr);
|
||||||
if (!hasPr) {
|
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();
|
const now = Date.now();
|
||||||
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
|
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
|
||||||
return;
|
return;
|
||||||
@@ -520,10 +553,6 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTerminalPrState(entry.status?.pr?.state)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = Date.now() - entry.lastRefreshAt;
|
const elapsed = Date.now() - entry.lastRefreshAt;
|
||||||
const nextInterval = getOpenPrRefreshInterval(entry.status);
|
const nextInterval = getOpenPrRefreshInterval(entry.status);
|
||||||
if (elapsed < nextInterval) {
|
if (elapsed < nextInterval) {
|
||||||
@@ -850,6 +879,11 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
|||||||
if (!identity?.directory || !identity.branch) {
|
if (!identity?.directory || !identity.branch) {
|
||||||
return false;
|
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);
|
const freshness = Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt);
|
||||||
return freshness > 0 && Date.now() - freshness < PR_PERSIST_TTL_MS;
|
return freshness > 0 && Date.now() - freshness < PR_PERSIST_TTL_MS;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,8 +75,9 @@
|
|||||||
- It resolves those remotes into GitHub repos.
|
- 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 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 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.
|
- It first searches for **open** PRs by likely source owner plus exact head branch.
|
||||||
- If that fails, it falls back to broader GitHub search for the branch name.
|
- If that fails, it falls back to broader GitHub search for open PRs on the branch name.
|
||||||
|
- Closed/merged PRs are intentionally not associated with branch status; historical PR browsing stays on explicit list/detail endpoints.
|
||||||
- `403` and `404` during repo lookups are treated as expected gaps, not hard errors.
|
- `403` and `404` during repo lookups are treated as expected gaps, not hard errors.
|
||||||
|
|
||||||
## Shared client state model
|
## Shared client state model
|
||||||
@@ -108,11 +109,16 @@
|
|||||||
- Open PR with pending checks -> refresh about every `1m`.
|
- Open PR with pending checks -> refresh about every `1m`.
|
||||||
- Open PR with non-pending checks -> refresh about every `5m`.
|
- Open PR with non-pending checks -> refresh about every `5m`.
|
||||||
- Open PR without a stable checks signal -> refresh about every `2m`.
|
- Open PR without a stable checks signal -> refresh about every `2m`.
|
||||||
- Closed or merged PR -> stop regular polling.
|
- Closed or merged PR -> discovery refresh every `5m` (do not permanently stop polling).
|
||||||
- Hidden tab -> skip polling.
|
- Hidden tab -> skip polling.
|
||||||
- Non-forced refreshes use a `90s` TTL.
|
- Non-forced refreshes use a `90s` TTL.
|
||||||
- Failed non-forced attempts also observe the `90s` TTL so transient server or rate-limit failures cannot retry on every sidebar update. Forced user/action refreshes bypass this guard.
|
- Failed non-forced attempts also observe the `90s` TTL so transient server or rate-limit failures cannot retry on every sidebar update. Forced user/action refreshes bypass this guard.
|
||||||
|
|
||||||
|
## Persistence notes for terminal PRs
|
||||||
|
|
||||||
|
- Closed/merged branch-status entries are not written to local storage.
|
||||||
|
- Legacy persisted terminal entries are stripped on hydrate (`pr: null`) and marked unresolved until the next refresh.
|
||||||
|
|
||||||
## Background tracking rules
|
## Background tracking rules
|
||||||
|
|
||||||
- Track up to `50` likely directories.
|
- Track up to `50` likely directories.
|
||||||
|
|||||||
@@ -440,61 +440,62 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
|
|||||||
|
|
||||||
const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean));
|
const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean));
|
||||||
|
|
||||||
for (const state of ['open', 'closed']) {
|
// Branch status only discovers open PRs. Closed/merged history belongs to
|
||||||
let response;
|
// explicit PR list/detail workflows, not automatic branch association.
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await octokit.rest.search.issuesAndPullRequests({
|
||||||
|
q: `is:pr state:open head:${branch}`,
|
||||||
|
per_page: 20,
|
||||||
|
});
|
||||||
|
// If we get here, search API works for this repo — clear the disabled flag
|
||||||
|
_searchApiDisabledRepos.delete(repoKey);
|
||||||
|
} catch (error) {
|
||||||
|
noteIfGitHubRateLimit(error);
|
||||||
|
if (error?.status === 403) {
|
||||||
|
_searchApiDisabledRepos.set(repoKey, Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (error?.status === 404) {
|
||||||
|
rememberSearchMiss(missKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
response = await octokit.rest.search.issuesAndPullRequests({
|
const prResponse = await octokit.rest.pulls.get({
|
||||||
q: `is:pr state:${state} head:${branch}`,
|
owner: repo.owner,
|
||||||
per_page: 20,
|
repo: repo.repo,
|
||||||
|
pull_number: item.number,
|
||||||
});
|
});
|
||||||
// If we get here, search API works for this repo — clear the disabled flag
|
const pr = prResponse?.data;
|
||||||
_searchApiDisabledRepos.delete(repoKey);
|
if (!pr || normalizeText(pr.head?.ref) !== branch) {
|
||||||
} catch (error) {
|
continue;
|
||||||
noteIfGitHubRateLimit(error);
|
|
||||||
if (error?.status === 403) {
|
|
||||||
_searchApiDisabledRepos.set(repoKey, Date.now());
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (error?.status === 404) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
throw error;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rememberSearchMiss(missKey);
|
rememberSearchMiss(missKey);
|
||||||
@@ -511,24 +512,25 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates,
|
|||||||
.filter((pr) => matcher.matches(pr, target.repo.repo))
|
.filter((pr) => matcher.matches(pr, target.repo.repo))
|
||||||
.sort((left, right) => matcher.compare(left, right, target.repo.repo))[0] ?? null;
|
.sort((left, right) => matcher.compare(left, right, target.repo.repo))[0] ?? null;
|
||||||
|
|
||||||
for (const state of ['open', 'closed']) {
|
// Branch status associates the current head with an open PR only. Returning a
|
||||||
// Shared per-repo list first: one pulls.list answers every branch of the
|
// closed/merged PR here made the client cache a terminal status that could not
|
||||||
// repo within the TTL. A miss in a complete list is authoritative — skip
|
// self-heal until a manual forced refresh.
|
||||||
// the per-branch query fan entirely.
|
const state = 'open';
|
||||||
let listWasComplete = false;
|
// Shared per-repo list first: one pulls.list answers every branch of the
|
||||||
try {
|
// repo within the TTL. A miss in a complete list is authoritative — skip
|
||||||
const listEntry = await getRepoPulls(octokit, target.repo, state, { force });
|
// the per-branch query fan entirely.
|
||||||
const fromList = pickPreferred(listEntry.prs);
|
let listWasComplete = false;
|
||||||
if (fromList) {
|
try {
|
||||||
return fromList;
|
const listEntry = await getRepoPulls(octokit, target.repo, state, { force });
|
||||||
}
|
const fromList = pickPreferred(listEntry.prs);
|
||||||
listWasComplete = listEntry.complete;
|
if (fromList) {
|
||||||
} catch {
|
return fromList;
|
||||||
// fall through to the precise per-branch queries
|
|
||||||
}
|
|
||||||
if (listWasComplete) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
listWasComplete = listEntry.complete;
|
||||||
|
} catch {
|
||||||
|
// fall through to the precise per-branch queries
|
||||||
|
}
|
||||||
|
if (!listWasComplete) {
|
||||||
if (coverage) {
|
if (coverage) {
|
||||||
coverage.authoritative = false;
|
coverage.authoritative = false;
|
||||||
}
|
}
|
||||||
@@ -551,6 +553,9 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates,
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Exported for focused unit tests of open-only branch matching.
|
||||||
|
export { findFirstMatchingPr };
|
||||||
|
|
||||||
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName, force = false }) {
|
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName, force = false }) {
|
||||||
// A deleted worktree can still have a session in the sidebar that keeps
|
// A deleted worktree can still have a session in the sidebar that keeps
|
||||||
// requesting its PR status. Bail before touching git or GitHub for a
|
// requesting its PR status. Bail before touching git or GitHub for a
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
const listMock = mock(async () => ({ data: [] }));
|
||||||
|
|
||||||
|
mock.module('../git/index.js', () => ({
|
||||||
|
getRemotes: async () => [],
|
||||||
|
getStatus: async () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('./repo/index.js', () => ({
|
||||||
|
resolveGitHubRepoFromDirectory: async () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('./rate-limit.js', () => ({
|
||||||
|
noteIfGitHubRateLimit: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { findFirstMatchingPr, invalidateRepoPullsCache } = await import('./pr-status.js');
|
||||||
|
|
||||||
|
const openPr = {
|
||||||
|
number: 15,
|
||||||
|
state: 'open',
|
||||||
|
head: {
|
||||||
|
ref: 'feature',
|
||||||
|
label: 'acme:feature',
|
||||||
|
user: { login: 'acme' },
|
||||||
|
repo: { owner: { login: 'acme' }, name: 'app' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const closedPr = {
|
||||||
|
number: 12,
|
||||||
|
state: 'closed',
|
||||||
|
merged_at: '2026-01-01T00:00:00Z',
|
||||||
|
head: {
|
||||||
|
ref: 'feature',
|
||||||
|
label: 'acme:feature',
|
||||||
|
user: { login: 'acme' },
|
||||||
|
repo: { owner: { login: 'acme' }, name: 'app' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('findFirstMatchingPr open-only branch status', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
listMock.mockReset();
|
||||||
|
invalidateRepoPullsCache('acme', 'app');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a matching open PR', async () => {
|
||||||
|
listMock.mockImplementation(async ({ state }) => {
|
||||||
|
if (state === 'open') {
|
||||||
|
return { data: [openPr] };
|
||||||
|
}
|
||||||
|
return { data: [closedPr] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pr = await findFirstMatchingPr({
|
||||||
|
octokit: { rest: { pulls: { list: listMock } } },
|
||||||
|
target: { repo: { owner: 'acme', repo: 'app' }, remoteName: 'origin' },
|
||||||
|
branch: 'feature',
|
||||||
|
sourceCandidates: [{ repo: { owner: 'acme', repo: 'app' } }],
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pr?.number).toBe(15);
|
||||||
|
expect(listMock.mock.calls.every((call) => call[0]?.state === 'open')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when only a closed/merged PR exists for the head branch', async () => {
|
||||||
|
listMock.mockImplementation(async ({ state }) => {
|
||||||
|
if (state === 'open') {
|
||||||
|
return { data: [] };
|
||||||
|
}
|
||||||
|
return { data: [closedPr] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pr = await findFirstMatchingPr({
|
||||||
|
octokit: { rest: { pulls: { list: listMock } } },
|
||||||
|
target: { repo: { owner: 'acme', repo: 'app' }, remoteName: 'origin' },
|
||||||
|
branch: 'feature',
|
||||||
|
sourceCandidates: [{ repo: { owner: 'acme', repo: 'app' } }],
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pr).toBeNull();
|
||||||
|
expect(listMock.mock.calls.every((call) => call[0]?.state === 'open')).toBe(true);
|
||||||
|
expect(listMock.mock.calls.some((call) => call[0]?.state === 'closed')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user