Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/__tests__/mergify.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const {
MergifyCache,
PrStatusCache,
StackContextCache,
findTimelineActions,
isPullRequestOpen,
isPullRequestQueued,
Expand Down Expand Up @@ -1195,6 +1196,76 @@ describe("PrStatusCache", () => {
});
});

describe("StackContextCache", () => {
beforeEach(() => {
localStorage.clear();
jest.spyOn(Date, "now").mockImplementation(() => 1000);
});
afterEach(() => {
jest.restoreAllMocks();
});

it("returns null on cache miss", () => {
const cache = new StackContextCache();
expect(cache.get("o", "r", 1)).toBeNull();
});

it("returns stored stackData and revisionData on cache hit", () => {
const cache = new StackContextCache();
const stackData = { schema_version: 1, pulls: [{ number: 1 }] };
const revisionData = { schema_version: 1, entries: [] };
cache.update("o", "r", 1, stackData, revisionData);
const got = cache.get("o", "r", 1);
expect(got).not.toBeNull();
expect(got.stackData).toEqual(stackData);
expect(got.revisionData).toEqual(revisionData);
});

it("expires entries after TTL", () => {
const cache = new StackContextCache(500);
cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null);
Date.now.mockImplementation(() => 1501);
expect(cache.get("o", "r", 1)).toBeNull();
});

it("returns null on corrupted entry", () => {
const cache = new StackContextCache();
const k = cache.key("o", "r", 1);
localStorage.setItem(k, "not-json");
const errSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
expect(cache.get("o", "r", 1)).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});

it("remove deletes the entry", () => {
const cache = new StackContextCache();
cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null);
expect(cache.get("o", "r", 1)).not.toBeNull();
cache.remove("o", "r", 1);
expect(cache.get("o", "r", 1)).toBeNull();
});

it("expires after 1h by default", () => {
const cache = new StackContextCache();
cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null);
Date.now.mockImplementation(() => 1000 + 59 * 60 * 1000);
expect(cache.get("o", "r", 1)).not.toBeNull();
Date.now.mockImplementation(() => 1000 + 61 * 60 * 1000);
expect(cache.get("o", "r", 1)).toBeNull();
});

it("preserves null revisionData", () => {
const cache = new StackContextCache();
cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null);
const got = cache.get("o", "r", 1);
expect(got.stackData).not.toBeNull();
expect(got.revisionData).toBeNull();
});
});

describe("fetchPrStatus", () => {
afterEach(() => {
jest.restoreAllMocks();
Expand Down
55 changes: 55 additions & 0 deletions src/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,58 @@ export class PrStatusCache {
}
}
}

export class StackContextCache {
constructor(expirationMs = 60 * 60 * 1000) {
this.PREFIX = "mergify_browser_extension_stack_ctx";
this.expirationMs = expirationMs;
}

key(org, repo, num) {
return `${this.PREFIX}_${org}_${repo}_${num}`;
}
Comment thread
jd marked this conversation as resolved.

get(org, repo, num) {
const k = this.key(org, repo, num);
try {
const raw = localStorage.getItem(k);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() - data.timestamp > this.expirationMs) {
localStorage.removeItem(k);
return null;
}
return {
stackData: data.stackData ?? null,
revisionData: data.revisionData ?? null,
};
} catch (e) {
console.error("StackContextCache get failed:", e);
return null;
}
}

update(org, repo, num, stackData, revisionData) {
const k = this.key(org, repo, num);
try {
localStorage.setItem(
k,
JSON.stringify({
stackData,
revisionData,
timestamp: Date.now(),
}),
);
} catch (e) {
console.error("StackContextCache update failed:", e);
}
}

remove(org, repo, num) {
try {
localStorage.removeItem(this.key(org, repo, num));
} catch (e) {
console.error("StackContextCache remove failed:", e);
}
}
}
Comment thread
jd marked this conversation as resolved.
7 changes: 6 additions & 1 deletion src/mergify.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import {
MergifyCache as _MergifyCache,
PrStatusCache as _PrStatusCache,
StackContextCache as _StackContextCache,
} from "./cache.js";
import { debug } from "./debug.js";
import { getPullRequestData, isGitHubPullRequestPage } from "./dom.js";
Expand All @@ -36,7 +37,11 @@ export * from "./timestamps.js";
// Re-export everything for the test suite (which imports from "../mergify").
// Cache classes are re-exported as named exports (not export *) so that
// jest.spyOn can replace them on the module object in tests.
export { _MergifyCache as MergifyCache, _PrStatusCache as PrStatusCache };
export {
_MergifyCache as MergifyCache,
_PrStatusCache as PrStatusCache,
_StackContextCache as StackContextCache,
};

// Clear cached PR statuses on a page reload (covers force-reload too —
// browsers don't expose hard-vs-soft reload to JS, so we treat any reload
Expand Down
53 changes: 52 additions & 1 deletion src/stacks.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PrStatusCache } from "./cache.js";
import { PrStatusCache, StackContextCache } from "./cache.js";
import { debug } from "./debug.js";
import { findFirstElement, readPrStatusFromDocument } from "./dom.js";
import { getLogoSvg, parseSvg } from "./logo.js";
Expand Down Expand Up @@ -30,6 +30,7 @@ export const CONTEXT_PANEL_TARGETS = [
export const _commentBodyCache = new Map();
export const _inflightStatusFetches = new Map();
export const _prStatusCache = new PrStatusCache();
export const _stackContextCache = new StackContextCache();
export let _contextRenderGeneration = 0;

export function extractMarkerJson(body, prefix) {
Expand Down Expand Up @@ -707,13 +708,49 @@ export function removeContextSurfaces(currentPull) {

export async function renderMergifyContext(currentPull) {
const generation = ++_contextRenderGeneration;

// Cache-first render: build the panel + nav from the last known good
// stack/revision data so the surfaces appear before the network roundtrips
// settle. The network refresh below replaces them in place (injectContextPanel
// / injectStackNav dedupe via data-mergify-hash, so identical data is a no-op).
const cached = _stackContextCache.get(
currentPull.org,
currentPull.repo,
currentPull.number,
);
if (cached) {
try {
const cachedPanel = buildContextPanel(
cached.stackData,
cached.revisionData,
currentPull,
);
if (cachedPanel) {
injectContextPanel(cachedPanel);
injectStackNav(cached.stackData, currentPull);
}
} catch (e) {
debug("Cache-first render failed; discarding entry:", e);
_stackContextCache.remove(
currentPull.org,
currentPull.repo,
currentPull.number,
);
}
}

const bodies = await fetchCommentBodies(
currentPull.org,
currentPull.repo,
currentPull.number,
);
if (generation !== _contextRenderGeneration) return;
if (bodies.length === 0) {
_stackContextCache.remove(
currentPull.org,
currentPull.repo,
currentPull.number,
);
removeContextSurfaces(currentPull);
return;
}
Expand All @@ -722,9 +759,23 @@ export async function renderMergifyContext(currentPull) {
const revisionData = parseRevisionMarker(bodies, currentPull.number);
const panel = buildContextPanel(stackData, revisionData, currentPull);
if (!panel) {
_stackContextCache.remove(
currentPull.org,
currentPull.repo,
currentPull.number,
);
removeContextSurfaces(currentPull);
return;
}

_stackContextCache.update(
currentPull.org,
currentPull.repo,
currentPull.number,
stackData,
revisionData,
);

injectContextPanel(panel);
injectStackNav(stackData, currentPull);

Expand Down
Loading