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
@@ -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"
|
||||
|
||||
let runtimeKey = "runtime-a"
|
||||
@@ -166,3 +166,246 @@ describe("GitHub PR status cache ownership", () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user