fix(github): refreshed stale issue and pr views

Refresh soft-expired issue and PR view cache rows synchronously before returning content, while keeping PR diff rows on stale-first refresh semantics.

Add stale fallback warnings when a live refresh fails and cover the cache/protocol behavior in tests.

Fixes #2684
This commit is contained in:
roboomp
2026-06-15 15:25:54 +00:00
parent c6fd50b8e6
commit 47fb727582
5 changed files with 144 additions and 21 deletions
@@ -299,7 +299,7 @@ describe("getOrFetchView (TTL semantics)", () => {
expect(fetchFresh).not.toHaveBeenCalled();
});
it("returns cached row AND schedules a background refresh past soft TTL", async () => {
it("refreshes issue rows synchronously past soft TTL", async () => {
const settings = Settings.isolated({
"github.cache.softTtlSec": 60,
"github.cache.hardTtlSec": 86400,
@@ -326,21 +326,94 @@ describe("getOrFetchView (TTL semantics)", () => {
fetchFresh,
settings,
});
expect(result.status).toBe("stale");
expect(result.rendered).toBe("old");
// Background refresh runs on a microtask; flush + give the write a tick
// to land before asserting.
await Promise.resolve();
// Wait for the chained .then() (writes) to settle.
await new Promise<void>(resolve => setTimeout(resolve, 5));
expect(result.status).toBe("refreshed");
expect(result.rendered).toBe("refreshed");
expect(fetchFresh).toHaveBeenCalledTimes(1);
const updated = getCached<{ refreshed: boolean }>(TEST_REPO, "issue", 50, true);
expect(updated?.rendered).toBe("refreshed");
expect(updated?.payload.refreshed).toBe(true);
});
it("falls back to stale issue rows when a soft-expired synchronous refresh fails", async () => {
const settings = Settings.isolated({
"github.cache.softTtlSec": 60,
"github.cache.hardTtlSec": 86400,
});
const fetchFresh = vi.fn(
async (): Promise<{
rendered: string;
sourceUrl: undefined;
payload: { number: number; refreshed: boolean };
}> => {
throw new Error("offline");
},
);
putCached({
repo: TEST_REPO,
kind: "issue",
number: 51,
includeComments: true,
payload: { number: 51, refreshed: false },
rendered: "old",
fetchedAt: Date.now() - 5 * 60_000,
});
const result = await getOrFetchView({
repo: TEST_REPO,
kind: "issue",
number: 51,
includeComments: true,
fetchFresh,
settings,
});
expect(result.status).toBe("stale");
expect(result.rendered).toBe("old");
expect(fetchFresh).toHaveBeenCalledTimes(1);
});
it("keeps PR diff rows stale-first past soft TTL", async () => {
const settings = Settings.isolated({
"github.cache.softTtlSec": 60,
"github.cache.hardTtlSec": 86400,
});
const fetchFresh = vi.fn(async () => ({
rendered: "refreshed-diff",
sourceUrl: undefined,
payload: { files: [], refreshed: true },
}));
putCached({
repo: TEST_REPO,
kind: "pr-diff",
number: 52,
includeComments: false,
payload: { files: [], refreshed: false },
rendered: "old-diff",
fetchedAt: Date.now() - 5 * 60_000,
});
const result = await getOrFetchView({
repo: TEST_REPO,
kind: "pr-diff",
number: 52,
includeComments: false,
fetchFresh,
settings,
});
expect(result.status).toBe("stale");
expect(result.rendered).toBe("old-diff");
await Promise.resolve();
await Bun.sleep(5);
expect(fetchFresh).toHaveBeenCalledTimes(1);
const updated = getCached<{ refreshed: boolean }>(TEST_REPO, "pr-diff", 52, false);
expect(updated?.rendered).toBe("refreshed-diff");
expect(updated?.payload.refreshed).toBe(true);
});
it("treats past-hard-TTL rows as cache misses (fetcher runs)", async () => {
const settings = Settings.isolated({
"github.cache.softTtlSec": 60,