diff --git a/src/__tests__/mergify.test.js b/src/__tests__/mergify.test.js index 36a0548..21068bb 100644 --- a/src/__tests__/mergify.test.js +++ b/src/__tests__/mergify.test.js @@ -1,5 +1,6 @@ const { MergifyCache, + PrStatusCache, findTimelineActions, isPullRequestOpen, isPullRequestQueued, @@ -14,6 +15,20 @@ const { convertMergifyTimestamps, isMergifyBotComment, formatLocalTime, + parseStackMarker, + parseRevisionMarker, + STACK_MARKER_PREFIX, + REVISION_MARKER_PREFIX, + MARKER_SUFFIX, + fetchPrStatus, + gatherPrStatuses, + buildContextPanel, + updateStackDotStatus, + injectContextPanel, + renderMergifyContext, + clearCommentsCache, + buildStackNav, + injectStackNav, } = require("../mergify"); const { loadFixture, injectFixtureInDOM } = require("./utils"); @@ -797,3 +812,1531 @@ describe("convertMergifyTimestamps", () => { expect(code.textContent).toBe("9999-99-99 99:99 UTC"); }); }); + +describe("parseStackMarker", () => { + function makeBody(payload, titleRows = "") { + return ( + "Stack:\n" + + "| # | Pull Request | Link | |\n" + + "|--:|---|---|---|\n" + + titleRows + + `\n\n` + ); + } + + it("returns null when no comment carries the marker", () => { + expect( + parseStackMarker(["plain text", "no marker here"], 122), + ).toBeNull(); + }); + + it("parses a well-formed marker and merges titles from the table", () => { + const payload = { + schema_version: 1, + stack_id: "feature/auth", + pulls: [ + { + number: 121, + change_id: "Ia", + head_sha: "ab12cd3", + base_branch: "main", + dest_branch: "feature/auth", + is_current: false, + }, + { + number: 122, + change_id: "Ib", + head_sha: "ef45gh6", + base_branch: "feature/auth", + dest_branch: "feature/auth", + is_current: true, + }, + ], + }; + const titleRows = + "| 0 | Auth scaffolding | [#121](https://x/121) | |\n" + + "| 1 | Token refresh | [#122](https://x/122) | 👈 |\n"; + const result = parseStackMarker([makeBody(payload, titleRows)], 122); + expect(result.stack_id).toBe("feature/auth"); + expect(result.pulls).toHaveLength(2); + expect(result.pulls[0]).toMatchObject({ + number: 121, + title: "Auth scaffolding", + is_current: false, + }); + expect(result.pulls[1]).toMatchObject({ + number: 122, + title: "Token refresh", + is_current: true, + }); + }); + + it("uses the latest marker when multiple are present", () => { + function payload(stack_id) { + return { + schema_version: 1, + stack_id, + pulls: [ + { + number: 1, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: stack_id, + is_current: true, + }, + ], + }; + } + const a = ``; + const b = ``; + expect(parseStackMarker([a, b], 1).stack_id).toBe("new"); + }); + + it("returns null on malformed JSON", () => { + const body = `${STACK_MARKER_PREFIX}{not json}${MARKER_SUFFIX}`; + expect(parseStackMarker([body], 122)).toBeNull(); + }); + + it("returns null on schema_version != 1", () => { + const body = ``; + expect(parseStackMarker([body], 122)).toBeNull(); + }); + + it("returns null when the marker's is_current PR doesn't match — guards against stale-DOM SPA fetches", () => { + // Marker says #30163 is the current PR, but we're rendering for #30164. + // The fetched comment came from #30163's page during a navigation race. + const payload = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 30163, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: true, + }, + { + number: 30164, + change_id: "i", + head_sha: "h", + base_branch: "x", + dest_branch: "x", + is_current: false, + }, + ], + }; + const body = ``; + expect(parseStackMarker([body], 30164)).toBeNull(); + // Same marker matches when rendering for #30163. + expect(parseStackMarker([body], 30163)).not.toBeNull(); + }); + + it("falls back to 'PR #N' when title row is missing", () => { + const payload = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 999, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: true, + }, + ], + }; + const body = ``; + expect(parseStackMarker([body], 999).pulls[0].title).toBe("PR #999"); + }); + + it("unescapes \\| in titles", () => { + const payload = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 5, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: true, + }, + ], + }; + const titleRows = "| 0 | Pipe \\| in title | [#5](https://x/5) | |\n"; + const body = + "| # | Pull Request | Link | |\n|--:|---|---|---|\n" + + titleRows + + ``; + expect(parseStackMarker([body], 5).pulls[0].title).toBe( + "Pipe | in title", + ); + }); +}); + +describe("parseRevisionMarker", () => { + function makeMarker(payload) { + return ``; + } + + it("returns null when no comment matches the pull number", () => { + const body = makeMarker({ + schema_version: 1, + pull_number: 999, + entries: [], + }); + expect(parseRevisionMarker([body], 122)).toBeNull(); + }); + + it("parses a matching marker", () => { + const payload = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "initial", + old_sha: null, + new_sha: "ab12cd3", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: null, + }, + { + number: 2, + change_type: "amend", + old_sha: "ab12cd3", + new_sha: "cd34ef5", + timestamp_iso: "2026-04-23T16:02:00Z", + compare_url: "https://x/compare/ab12cd3...cd34ef5", + }, + ], + }; + const result = parseRevisionMarker([makeMarker(payload)], 122); + expect(result.entries).toHaveLength(2); + expect(result.entries[1].change_type).toBe("amend"); + }); + + it("returns null on malformed JSON", () => { + const body = `${REVISION_MARKER_PREFIX}{bad}${MARKER_SUFFIX}`; + expect(parseRevisionMarker([body], 122)).toBeNull(); + }); + + it("captures the reason from a 5-col markdown table for any change_type", () => { + const payload = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "rebase", + old_sha: "a", + new_sha: "b", + timestamp_iso: "t", + compare_url: "u", + }, + { + number: 2, + change_type: "unknown", + old_sha: "b", + new_sha: "c", + timestamp_iso: "t", + compare_url: "u", + }, + { + number: 3, + change_type: "amend", + old_sha: "c", + new_sha: "d", + timestamp_iso: "t", + compare_url: "u", + }, + ], + }; + const body = + "### Revision history\n\n" + + "| # | Type | Changes | Reason | Date |\n" + + "|---|------|---------|--------|------|\n" + + "| 1 | rebase | [link](u) | rebased onto main | 2026-04-22 09:00 UTC |\n" + + "| 2 | unknown | [link](u) | follow-up after review | 2026-04-22 10:00 UTC |\n" + + "| 3 | amend | [link](u) | | 2026-04-22 11:00 UTC |\n" + + `${REVISION_MARKER_PREFIX}${JSON.stringify(payload)}${MARKER_SUFFIX}`; + const result = parseRevisionMarker([body], 122); + // change_type values must come from the JSON unchanged. + expect(result.entries[0].change_type).toBe("rebase"); + expect(result.entries[1].change_type).toBe("unknown"); + expect(result.entries[2].change_type).toBe("amend"); + // Reasons come from the markdown. + expect(result.entries[0].reason).toBe("rebased onto main"); + expect(result.entries[1].reason).toBe("follow-up after review"); + expect(result.entries[2].reason).toBeNull(); + }); + + it("returns null on schema_version mismatch", () => { + const body = makeMarker({ + schema_version: 2, + pull_number: 122, + entries: [], + }); + expect(parseRevisionMarker([body], 122)).toBeNull(); + }); + + it("uses the latest matching marker when multiple are present", () => { + const a = makeMarker({ + schema_version: 1, + pull_number: 122, + entries: [{ number: 1 }], + }); + const b = makeMarker({ + schema_version: 1, + pull_number: 122, + entries: [{ number: 2 }], + }); + expect(parseRevisionMarker([a, b], 122).entries[0].number).toBe(2); + }); +}); + +describe("PrStatusCache", () => { + beforeEach(() => { + localStorage.clear(); + jest.spyOn(Date, "now").mockImplementation(() => 1000); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns null on cache miss", () => { + const cache = new PrStatusCache(); + expect(cache.get("o", "r", 1, "abc")).toBeNull(); + }); + + it("returns stored status on cache hit", () => { + const cache = new PrStatusCache(); + cache.update("o", "r", 1, "abc", "open"); + expect(cache.get("o", "r", 1, "abc")).toBe("open"); + }); + + it("treats different head_sha as a miss", () => { + const cache = new PrStatusCache(); + cache.update("o", "r", 1, "abc", "open"); + expect(cache.get("o", "r", 1, "def")).toBeNull(); + }); + + it("expires entries after TTL", () => { + const cache = new PrStatusCache(500); + cache.update("o", "r", 1, "abc", "open"); + Date.now.mockImplementation(() => 1501); + expect(cache.get("o", "r", 1, "abc")).toBeNull(); + }); + + it("returns null on corrupted entry", () => { + const cache = new PrStatusCache(); + const k = cache.key("o", "r", 1, "abc"); + localStorage.setItem(k, "not-json"); + const errSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + expect(cache.get("o", "r", 1, "abc")).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it("clearAll removes only entries with our prefix", () => { + const cache = new PrStatusCache(); + cache.update("o", "r", 1, "h", "open"); + cache.update("o", "r", 2, "h", "merged"); + localStorage.setItem("unrelated_key", "keep me"); + cache.clearAll(); + expect(cache.get("o", "r", 1, "h")).toBeNull(); + expect(cache.get("o", "r", 2, "h")).toBeNull(); + expect(localStorage.getItem("unrelated_key")).toBe("keep me"); + }); + + it("expires after 1h by default", () => { + const cache = new PrStatusCache(); + cache.update("o", "r", 1, "h", "open"); + // 59 minutes — still valid + Date.now.mockImplementation(() => 1000 + 59 * 60 * 1000); + expect(cache.get("o", "r", 1, "h")).toBe("open"); + // 61 minutes — expired + Date.now.mockImplementation(() => 1000 + 61 * 60 * 1000); + expect(cache.get("o", "r", 1, "h")).toBeNull(); + }); +}); + +describe("fetchPrStatus", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockFetchHtml(html, ok = true) { + global.fetch = jest.fn().mockResolvedValue({ + ok, + text: () => Promise.resolve(html), + }); + } + + it("returns 'open' for an opened PR", async () => { + mockFetchHtml(''); + await expect(fetchPrStatus("o", "r", 1)).resolves.toBe("open"); + }); + + it("returns 'merged' for a merged PR", async () => { + mockFetchHtml(''); + await expect(fetchPrStatus("o", "r", 1)).resolves.toBe("merged"); + }); + + it("returns 'closed' for a closed PR", async () => { + mockFetchHtml(''); + await expect(fetchPrStatus("o", "r", 1)).resolves.toBe("closed"); + }); + + it("returns 'draft' for a draft PR", async () => { + mockFetchHtml(''); + await expect(fetchPrStatus("o", "r", 1)).resolves.toBe("draft"); + }); + + it("returns 'unknown' on non-OK response", async () => { + mockFetchHtml("", false); + await expect(fetchPrStatus("o", "r", 1)).resolves.toBe("unknown"); + }); + + it("returns 'unknown' on fetch error", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("net")); + await expect(fetchPrStatus("o", "r", 1)).resolves.toBe("unknown"); + }); +}); + +describe("gatherPrStatuses", () => { + beforeEach(() => { + // Navigate away from any PR URL left by earlier tests so that + // background RAF callbacks from the MutationObserver / onPageUpdate + // path do not trigger extra fetch() calls during the concurrency test. + History.prototype.pushState.call(window.history, {}, "", "/"); + }); + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it("uses cached statuses without calling fetch", async () => { + const cache = new PrStatusCache(); + cache.update("o", "r", 1, "h", "open"); + const fetchSpy = jest.fn(); + global.fetch = fetchSpy; + const items = [{ org: "o", repo: "r", num: 1, head_sha: "h" }]; + const resolved = []; + await gatherPrStatuses(items, cache, (item, status) => { + resolved.push([item.num, status]); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(resolved).toEqual([[1, "open"]]); + }); + + it("fetches misses, caches results, and calls onResolve", async () => { + const cache = new PrStatusCache(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve(''), + }); + const items = [{ org: "o", repo: "r", num: 2, head_sha: "h2" }]; + const resolved = []; + await gatherPrStatuses(items, cache, (item, status) => { + resolved.push([item.num, status]); + }); + expect(resolved).toEqual([[2, "merged"]]); + expect(cache.get("o", "r", 2, "h2")).toBe("merged"); + }); + + it("respects the concurrency cap", async () => { + const cache = new PrStatusCache(); + let inflight = 0; + let peak = 0; + global.fetch = jest.fn(async () => { + inflight += 1; + peak = Math.max(peak, inflight); + await new Promise((r) => setTimeout(r, 5)); + inflight -= 1; + return { + ok: true, + text: () => + Promise.resolve(''), + }; + }); + const items = Array.from({ length: 8 }, (_, i) => ({ + org: "o", + repo: "r", + num: i, + head_sha: `h${i}`, + })); + await gatherPrStatuses(items, cache, () => {}, 3); + expect(peak).toBeLessThanOrEqual(3); + }); +}); + +describe("buildContextPanel", () => { + function stack() { + return { + schema_version: 1, + stack_id: "feature/auth", + pulls: [ + { + number: 122, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "feature/auth", + is_current: true, + title: "PR title", + }, + ], + }; + } + + function rev() { + return { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "initial", + old_sha: null, + new_sha: "ab12cd3", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: null, + }, + ], + }; + } + + function ctx() { + return { org: "o", repo: "r", number: 122 }; + } + + it("returns null when both inputs are null", () => { + expect(buildContextPanel(null, null, ctx())).toBeNull(); + }); + + it("returns a panel with id 'mergify-context' when at least one is present", () => { + const el = buildContextPanel(stack(), null, ctx()); + expect(el.id).toBe("mergify-context"); + }); + + it("renders only the stack column when revision is null", () => { + const el = buildContextPanel(stack(), null, ctx()); + expect( + el.querySelector('[data-mergify-section="stack"]'), + ).not.toBeNull(); + expect( + el.querySelector('[data-mergify-section="revisions"]'), + ).toBeNull(); + }); + + it("renders only the revisions column when stack is null", () => { + const el = buildContextPanel(null, rev(), ctx()); + expect(el.querySelector('[data-mergify-section="stack"]')).toBeNull(); + expect( + el.querySelector('[data-mergify-section="revisions"]'), + ).not.toBeNull(); + }); + + it("renders both columns when both inputs are present", () => { + const el = buildContextPanel(stack(), rev(), ctx()); + expect( + el.querySelector('[data-mergify-section="stack"]'), + ).not.toBeNull(); + expect( + el.querySelector('[data-mergify-section="revisions"]'), + ).not.toBeNull(); + }); + + it("renders stack rows in base-on-top order with current PR accent", () => { + const stackData = { + schema_version: 1, + stack_id: "feature/auth", + pulls: [ + { + number: 121, + change_id: "i1", + head_sha: "h1", + base_branch: "main", + dest_branch: "feature/auth", + is_current: false, + title: "Auth scaffolding", + }, + { + number: 122, + change_id: "i2", + head_sha: "h2", + base_branch: "feature/auth", + dest_branch: "feature/auth", + is_current: true, + title: "Token refresh", + }, + { + number: 123, + change_id: "i3", + head_sha: "h3", + base_branch: "feature/auth", + dest_branch: "feature/auth", + is_current: false, + title: "Logout flow", + }, + ], + }; + const el = buildContextPanel(stackData, null, ctx()); + const rows = el.querySelectorAll("[data-mergify-pr-row]"); + expect(rows).toHaveLength(3); + expect(rows[0].getAttribute("data-mergify-pr-row")).toBe("121"); + expect(rows[1].getAttribute("data-mergify-pr-row")).toBe("122"); + expect(rows[2].getAttribute("data-mergify-pr-row")).toBe("123"); + expect(rows[1].getAttribute("data-mergify-current")).toBe("true"); + expect(rows[0].getAttribute("data-mergify-current")).toBeNull(); + const dot121 = el.querySelector( + '[data-mergify-status-dot][data-mergify-pr-num="121"]', + ); + expect(dot121.getAttribute("data-mergify-head-sha")).toBe("h1"); + const dot122 = el.querySelector( + '[data-mergify-status-dot][data-mergify-pr-num="122"]', + ); + expect(dot122.getAttribute("data-mergify-head-sha")).toBe("h2"); + const dot123 = el.querySelector( + '[data-mergify-status-dot][data-mergify-pr-num="123"]', + ); + expect(dot123.getAttribute("data-mergify-head-sha")).toBe("h3"); + // Current row uses inset box-shadow (not border-left) so its content + // doesn't shift right and stays aligned with non-current rows. + const currentRow = rows[1]; + expect(currentRow.style.cssText).toMatch(/box-shadow:[^;]*inset/); + expect(currentRow.style.cssText).not.toMatch(/border-left:/); + }); + + it("section label includes the position-in-stack indicator", () => { + const stackData = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 1, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: false, + title: "a", + }, + { + number: 2, + change_id: "i", + head_sha: "h", + base_branch: "x", + dest_branch: "x", + is_current: true, + title: "b", + }, + { + number: 3, + change_id: "i", + head_sha: "h", + base_branch: "x", + dest_branch: "x", + is_current: false, + title: "c", + }, + ], + }; + const el = buildContextPanel(stackData, null, { + org: "o", + repo: "r", + number: 2, + }); + const sectionLabel = el.querySelector( + '[data-mergify-section="stack"] div', + ); + expect(sectionLabel.textContent).toBe("STACK · 3 PRs · you are #2"); + }); + + it("makes each stack row an anchor to its PR", () => { + const stackData = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 122, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: true, + title: "x", + }, + ], + }; + const el = buildContextPanel(stackData, null, { + org: "o", + repo: "r", + number: 122, + }); + const row = el.querySelector('[data-mergify-pr-row="122"]'); + expect(row.tagName).toBe("A"); + expect(row.getAttribute("href")).toBe("/o/r/pull/122"); + }); + + it("renders stack rows with a status-dot placeholder by default", () => { + const stackData = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 122, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: true, + title: "x", + }, + ], + }; + const el = buildContextPanel(stackData, null, ctx()); + const dot = el.querySelector("[data-mergify-status-dot]"); + expect(dot).not.toBeNull(); + expect(dot.getAttribute("data-mergify-status")).toBe("unknown"); + }); + + it("updateStackDotStatus paints the dot and label", () => { + const stackData = { + schema_version: 1, + stack_id: "x", + pulls: [ + { + number: 122, + change_id: "i", + head_sha: "h", + base_branch: "main", + dest_branch: "x", + is_current: true, + title: "x", + }, + ], + }; + const el = buildContextPanel(stackData, null, ctx()); + updateStackDotStatus(el, 122, "merged"); + const dot = el.querySelector("[data-mergify-status-dot]"); + expect(dot.getAttribute("data-mergify-status")).toBe("merged"); + const lbl = el.querySelector("[data-mergify-status-label]"); + expect(lbl.textContent).toBe("merged"); + }); + + it("renders revision dots in oldest→newest order", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "initial", + old_sha: null, + new_sha: "aaaaaaa", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: null, + }, + { + number: 2, + change_type: "amend", + old_sha: "aaaaaaa", + new_sha: "bbbbbbb", + timestamp_iso: "2026-04-23T16:02:00Z", + compare_url: "https://x/compare/aaaaaaa...bbbbbbb", + }, + ], + }; + const el = buildContextPanel(null, revData, ctx()); + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + expect(dots).toHaveLength(2); + expect(dots[0].getAttribute("data-mergify-change-type")).toBe( + "initial", + ); + expect(dots[1].getAttribute("data-mergify-change-type")).toBe("amend"); + }); + + it("links 'initial' to commit page and others to compare_url", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "initial", + old_sha: null, + new_sha: "aaaaaaa", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: null, + }, + { + number: 2, + change_type: "amend", + old_sha: "aaaaaaa", + new_sha: "bbbbbbb", + timestamp_iso: "2026-04-23T16:02:00Z", + compare_url: "https://x/compare/aaaaaaa...bbbbbbb", + }, + ], + }; + const el = buildContextPanel(null, revData, { + org: "o", + repo: "r", + number: 122, + }); + const links = el.querySelectorAll("[data-mergify-rev-dot]"); + expect(links[0].getAttribute("href")).toBe("/o/r/commit/aaaaaaa"); + expect(links[1].getAttribute("href")).toBe( + "https://x/compare/aaaaaaa...bbbbbbb", + ); + for (const a of links) expect(a.getAttribute("target")).toBe("_blank"); + }); + + it("marks the latest revision with data-mergify-latest", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "initial", + old_sha: null, + new_sha: "a", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: null, + }, + { + number: 2, + change_type: "amend", + old_sha: "a", + new_sha: "b", + timestamp_iso: "2026-04-23T09:14:00Z", + compare_url: "u", + }, + ], + }; + const el = buildContextPanel(null, revData, ctx()); + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + expect(dots[0].getAttribute("data-mergify-latest")).toBeNull(); + expect(dots[1].getAttribute("data-mergify-latest")).toBe("true"); + }); + + it("collapses middle entries when there are more than 6", () => { + const entries = Array.from({ length: 8 }, (_, i) => ({ + number: i + 1, + change_type: i === 0 ? "initial" : "amend", + old_sha: i === 0 ? null : `${i}`.repeat(7), + new_sha: `${i + 1}`.repeat(7), + timestamp_iso: `2026-04-${String(20 + i).padStart(2, "0")}T09:14:00Z`, + compare_url: i === 0 ? null : `u${i}`, + })); + const revData = { schema_version: 1, pull_number: 122, entries }; + const el = buildContextPanel(null, revData, ctx()); + const ellipsis = el.querySelector("[data-mergify-rev-ellipsis]"); + expect(ellipsis).not.toBeNull(); + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + // first + entry-before-last + last two = 4 visible dots + expect(dots).toHaveLength(4); + }); + + it("expands the ellipsis on click", () => { + const entries = Array.from({ length: 8 }, (_, i) => ({ + number: i + 1, + change_type: i === 0 ? "initial" : "amend", + old_sha: i === 0 ? null : `${i}`.repeat(7), + new_sha: `${i + 1}`.repeat(7), + timestamp_iso: `2026-04-${String(20 + i).padStart(2, "0")}T09:14:00Z`, + compare_url: i === 0 ? null : `u${i}`, + })); + const revData = { schema_version: 1, pull_number: 122, entries }; + const el = buildContextPanel(null, revData, ctx()); + const ellipsis = el.querySelector("[data-mergify-rev-ellipsis]"); + ellipsis.click(); + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + expect(dots).toHaveLength(8); + }); + + it("renders 'No revisions yet' when entries is empty", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [], + }; + const el = buildContextPanel(null, revData, ctx()); + expect( + el.querySelector("[data-mergify-revisions-empty]"), + ).not.toBeNull(); + expect(el.querySelectorAll("[data-mergify-rev-dot]")).toHaveLength(0); + }); + + it("revision dot anchors carry an aria-label", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "amend", + old_sha: "a", + new_sha: "b", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: "u", + }, + ], + }; + const el = buildContextPanel(null, revData, ctx()); + const dot = el.querySelector("[data-mergify-rev-dot]"); + expect(dot.getAttribute("aria-label")).toMatch(/Revision 1 \(amend\)/); + }); + + it("renders the reason inline under the dot whenever it is set", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "rebase", + old_sha: "a", + new_sha: "b", + timestamp_iso: "2026-04-22T09:14:00Z", + compare_url: "u", + reason: "rebased onto main", + }, + { + number: 2, + change_type: "unknown", + old_sha: "b", + new_sha: "c", + timestamp_iso: "2026-04-23T09:14:00Z", + compare_url: "u", + reason: "follow-up after review", + }, + { + number: 3, + change_type: "amend", + old_sha: "c", + new_sha: "d", + timestamp_iso: "2026-04-24T09:14:00Z", + compare_url: "u", + reason: null, + }, + ], + }; + const el = buildContextPanel(null, revData, ctx()); + const reasons = el.querySelectorAll("[data-mergify-rev-reason]"); + // Exactly the two entries with non-null reasons render the label. + expect(reasons).toHaveLength(2); + expect(reasons[0].textContent).toBe("rebased onto main"); + expect(reasons[1].textContent).toBe("follow-up after review"); + // Tooltip on the parent anchor matches the reason for accessibility. + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + expect(dots[0].getAttribute("title")).toBe("rebased onto main"); + expect(dots[2].hasAttribute("title")).toBe(false); + }); + + it("squashes consecutive rebase entries into one dot", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "initial", + old_sha: null, + new_sha: "aaaaaaa", + timestamp_iso: "2026-04-22T09:00:00Z", + compare_url: null, + }, + { + number: 2, + change_type: "rebase", + old_sha: "aaaaaaa", + new_sha: "bbbbbbb", + timestamp_iso: "2026-04-22T10:00:00Z", + compare_url: "u2", + }, + { + number: 3, + change_type: "rebase", + old_sha: "bbbbbbb", + new_sha: "ccccccc", + timestamp_iso: "2026-04-22T11:00:00Z", + compare_url: "u3", + }, + { + number: 4, + change_type: "rebase", + old_sha: "ccccccc", + new_sha: "ddddddd", + timestamp_iso: "2026-04-22T12:00:00Z", + compare_url: "u4", + }, + ], + }; + const el = buildContextPanel(null, revData, { + org: "o", + repo: "r", + number: 122, + }); + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + // initial + one squashed-rebase = 2 dots + expect(dots).toHaveLength(2); + const squashed = dots[1]; + expect(squashed.getAttribute("data-mergify-change-type")).toBe( + "rebase", + ); + expect(squashed.getAttribute("data-mergify-rebase-run")).toBe("3"); + // Compare URL should span the whole run: aaaaaaa -> ddddddd + expect(squashed.getAttribute("href")).toBe( + "/o/r/compare/aaaaaaa...ddddddd", + ); + // Latest dot wears the halo regardless of squashing. + expect(squashed.getAttribute("data-mergify-latest")).toBe("true"); + }); + + it("does not squash rebase entries separated by an amend", () => { + const revData = { + schema_version: 1, + pull_number: 122, + entries: [ + { + number: 1, + change_type: "rebase", + old_sha: "a", + new_sha: "b", + timestamp_iso: "2026-04-22T09:00:00Z", + compare_url: "u1", + }, + { + number: 2, + change_type: "amend", + old_sha: "b", + new_sha: "c", + timestamp_iso: "2026-04-22T10:00:00Z", + compare_url: "u2", + }, + { + number: 3, + change_type: "rebase", + old_sha: "c", + new_sha: "d", + timestamp_iso: "2026-04-22T11:00:00Z", + compare_url: "u3", + }, + ], + }; + const el = buildContextPanel(null, revData, ctx()); + const dots = el.querySelectorAll("[data-mergify-rev-dot]"); + expect(dots).toHaveLength(3); + for (const dot of dots) { + expect(dot.getAttribute("data-mergify-rebase-run")).toBeNull(); + } + }); + + it("produces a stable data-mergify-hash across rebuilds with equal inputs", () => { + const a = buildContextPanel(stack(), rev(), ctx()); + const b = buildContextPanel(stack(), rev(), ctx()); + expect(a.getAttribute("data-mergify-hash")).toBe( + b.getAttribute("data-mergify-hash"), + ); + }); +}); + +describe("injectContextPanel", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + function makePanel(hash = "abc") { + const p = document.createElement("div"); + p.id = "mergify-context"; + p.setAttribute("data-mergify-hash", hash); + return p; + } + + it("inserts the panel into the conversation target", () => { + document.body.innerHTML = + '