diff --git a/src/__tests__/mergify.test.js b/src/__tests__/mergify.test.js index 576eded..7029514 100644 --- a/src/__tests__/mergify.test.js +++ b/src/__tests__/mergify.test.js @@ -3,6 +3,7 @@ const { PrStatusCache, StackContextCache, findTimelineActions, + injectRowIntoMergeBox, isPullRequestOpen, isPullRequestQueued, resetQueueState, @@ -138,6 +139,215 @@ describe("findTimelineActions", () => { }); }); +describe("injectRowIntoMergeBox", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("injects into the new merge-status sidebar (Primer dialog)", () => { + // Mirrors the markup GitHub renders for the new sidebar: the + // border-container lives in a top-level
, + // not under [data-testid="mergebox-partial"]. + document.body.innerHTML = ` +
+
+
+
+
+
+
+
`; + + const injected = injectRowIntoMergeBox(); + + expect(injected).toBe(true); + const row = document.querySelector("[data-mergify-merge-box-row]"); + expect(row).not.toBeNull(); + // Row is appended at the bottom of the new sidebar's section container. + const container = document.querySelector( + '[data-testid="mergebox-border-container"]', + ); + expect(container.lastElementChild).toBe(row); + }); + + it('does not collide with GitHub\'s native
sub-section', () => { + document.body.innerHTML = ` +
+
native app section
+
+
`; + + const injected = injectRowIntoMergeBox(); + + expect(injected).toBe(true); + const ourRow = document.querySelector("[data-mergify-merge-box-row]"); + expect(ourRow).not.toBeNull(); + expect(ourRow.tagName).toBe("DIV"); + // The native section is untouched and still present. + const nativeSection = document.querySelector("section#mergify"); + expect(nativeSection).not.toBeNull(); + }); + + it("falls back to the legacy mergebox-partial / .border.rounded-2 anchor", () => { + document.body.innerHTML = ` +
+
+
+
+
`; + + const injected = injectRowIntoMergeBox(); + + expect(injected).toBe(true); + expect( + document.querySelector("[data-mergify-merge-box-row]"), + ).not.toBeNull(); + }); + + it("returns false when no merge box is present", () => { + document.body.innerHTML = "
"; + + expect(injectRowIntoMergeBox()).toBe(false); + expect( + document.querySelector("[data-mergify-merge-box-row]"), + ).toBeNull(); + }); + + it("injects into every anchor when several variants coexist on the page", () => { + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
`; + + expect(injectRowIntoMergeBox()).toBe(true); + + expect( + document.querySelectorAll("[data-mergify-merge-box-row]").length, + ).toBe(3); + expect( + document + .querySelector('[data-testid="mergebox-border-container"]') + .querySelector("[data-mergify-merge-box-row]"), + ).not.toBeNull(); + expect( + document + .querySelector('[data-testid="mergebox-partial"]') + .querySelector("[data-mergify-merge-box-row]"), + ).not.toBeNull(); + expect( + document + .querySelector(".mergeability-details") + .querySelector("[data-mergify-merge-box-row]"), + ).not.toBeNull(); + }); + + it("is idempotent per anchor when called repeatedly", () => { + document.body.innerHTML = ` +
+
+
`; + + injectRowIntoMergeBox(); + injectRowIntoMergeBox(); + injectRowIntoMergeBox(); + + expect( + document.querySelectorAll("[data-mergify-merge-box-row]").length, + ).toBe(1); + }); + + it("treats mergebox-border-container inside mergebox-partial as the default variant", () => { + // Recent GitHub deploys put [data-testid="mergebox-border-container"] + // on the bottom merge box's inner wrapper too. That should NOT + // trigger the sidebar layout. + document.body.innerHTML = ` +
+
+
+
+
`; + + injectRowIntoMergeBox(); + + const row = document.querySelector("[data-mergify-merge-box-row]"); + expect(row).not.toBeNull(); + expect(row.getAttribute("data-mergify-merge-box-row")).toBe("default"); + expect(row.className).toContain("bgColor-muted"); + }); + + it("uses the sidebar variant for the new merge-status sidebar", () => { + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+
+
`; + + injectRowIntoMergeBox(); + + const sidebarRow = document + .querySelector('[data-testid="mergebox-border-container"]') + .querySelector("[data-mergify-merge-box-row]"); + const legacyRow = document + .querySelector('[data-testid="mergebox-partial"]') + .querySelector("[data-mergify-merge-box-row]"); + + expect(sidebarRow.getAttribute("data-mergify-merge-box-row")).toBe( + "sidebar", + ); + // Sidebar variant: no muted background, top + bottom border rules + // matching GitHub's sub-section style, column layout, larger top margin. + expect(sidebarRow.className).toBe( + "border-top border-bottom color-border-subtle", + ); + expect(sidebarRow.style.flexDirection).toBe("column"); + expect(sidebarRow.style.marginTop).not.toBe(""); + + // First line: action buttons only. + const [firstLine, secondLine] = sidebarRow.children; + expect(firstLine.querySelector("button")).not.toBeNull(); + expect( + firstLine.querySelector('a[href="https://dashboard.mergify.com"]'), + ).toBeNull(); + + // Second line: queue + logs at the start (left), Mergify brand at + // the end (right). + expect(secondLine.style.justifyContent).toBe("space-between"); + const secondLineLinks = + secondLine.firstElementChild.querySelectorAll("a"); + expect(secondLineLinks[0].textContent).toBe("queue"); + expect(secondLineLinks[1].textContent).toBe("logs"); + const brandLink = secondLine.querySelector( + 'a[href="https://dashboard.mergify.com"]', + ); + expect(brandLink).not.toBeNull(); + expect(secondLine.lastElementChild).toBe(brandLink); + + expect(legacyRow.getAttribute("data-mergify-merge-box-row")).toBe( + "default", + ); + expect(legacyRow.className).toContain("bgColor-muted"); + // Default variant keeps queue + logs + brand on a single info block. + const legacyInfo = legacyRow.querySelector( + 'a[href="https://dashboard.mergify.com"]', + ).parentElement; + expect(legacyInfo.style.marginLeft).toBe("auto"); + }); +}); + describe("isPullRequestOpen", () => { afterEach(() => { document.body.innerHTML = ""; diff --git a/src/mergify.js b/src/mergify.js index 880a843..24e40c6 100644 --- a/src/mergify.js +++ b/src/mergify.js @@ -21,9 +21,10 @@ import { fetchQueueStateIfNeeded, getMergifyConfigurationStatus, isMergifyEnabledOnTheRepo, + MERGE_BOX_ROW_ATTR, resetQueueState, scheduleQueueStatePoll, - updateMergifyRow, + updateAllMergifyRows, } from "./queue.js"; import { renderMergifyContext } from "./stacks.js"; import { convertMergifyTimestamps } from "./timestamps.js"; @@ -66,46 +67,87 @@ export function resetForNavigation() { lastPullRequestUrl = null; } -function injectRowIntoMergeBox() { - // New merge box — the mergebox-partial may be inside discussion-timeline-actions - // or a separate element in the page (GitHub layout varies) - const mergeBoxPartial = document.querySelector( - '[data-testid="mergebox-partial"]', - ); - if (mergeBoxPartial) { - const mergeBoxContainer = - mergeBoxPartial.querySelector(".border.rounded-2"); - if (mergeBoxContainer) { - mergeBoxContainer.appendChild(buildMergifyRow()); - debug("Mergify section injected inside merge box container"); - return true; - } - } +// Anchors we inject the Mergify row into. Each entry resolves a container in +// the document (or returns null) and tells the injector how to attach the row. +// Multiple anchors can match on the same page — e.g. GitHub is rolling out a +// new merge-status sidebar dialog alongside the legacy bottom merge box — and +// we want the row visible on every variant the user can see. +const MERGE_BOX_ANCHORS = [ + { + name: "new merge-status sidebar", + // The new sidebar is rendered as a Primer dialog at the document + // root. The bottom merge box also exposes a + // [data-testid="mergebox-border-container"] on its inner section + // wrapper, so we only treat the testid as the sidebar anchor when + // it sits OUTSIDE the legacy mergebox-partial. + find: () => { + const containers = document.querySelectorAll( + '[data-testid="mergebox-border-container"]', + ); + return Array.from(containers).filter( + (c) => !c.closest('[data-testid="mergebox-partial"]'), + ); + }, + attach: (container, row) => container.appendChild(row), + variant: "sidebar", + }, + { + name: "merge-box-partial", + find: () => { + const partials = document.querySelectorAll( + '[data-testid="mergebox-partial"]', + ); + const containers = []; + for (const partial of partials) { + // Newer deploys add the testid to the inner section wrapper; + // older ones only have the utility class. Either anchors the + // default-variant row. + const inner = + partial.querySelector( + '[data-testid="mergebox-border-container"]', + ) || partial.querySelector(".border.rounded-2"); + if (inner) containers.push(inner); + } + return containers; + }, + attach: (container, row) => container.appendChild(row), + variant: "default", + }, + { + name: "discussion-timeline-actions merge-pr", + find: () => { + const detail = document.querySelector( + "div[class=discussion-timeline-actions]", + ); + const inner = detail?.querySelector(".merge-pr .border.rounded-2"); + return inner ? [inner] : []; + }, + attach: (container, row) => container.appendChild(row), + variant: "default", + }, + { + name: "classic mergeability-details", + find: () => document.querySelectorAll(".mergeability-details"), + attach: (container, row) => + container.insertBefore(row, container.firstChild), + variant: "default", + }, +]; - let detailSection = document.querySelector( - "div[class=discussion-timeline-actions]", - ); - if (detailSection) { - const mergeBoxContainer = detailSection.querySelector( - ".merge-pr .border.rounded-2", - ); - if (mergeBoxContainer) { - mergeBoxContainer.appendChild(buildMergifyRow()); - debug("Mergify section injected inside merge-pr container"); - return true; +export function injectRowIntoMergeBox() { + let injected = false; + for (const anchor of MERGE_BOX_ANCHORS) { + for (const container of anchor.find()) { + // Idempotency per container: skip if we already attached a row + // here. Multiple anchors can therefore coexist without growing + // duplicate rows on repeated MutationObserver ticks. + if (container.querySelector(`[${MERGE_BOX_ROW_ATTR}]`)) continue; + anchor.attach(container, buildMergifyRow(anchor.variant)); + debug(`Mergify section injected into ${anchor.name}`); + injected = true; } - debug("Merge box container not found yet, waiting for render"); - return false; - } - - // Classic merge box - detailSection = document.querySelector(".mergeability-details"); - if (detailSection) { - detailSection.insertBefore(buildMergifyRow(), detailSection.firstChild); - debug("Mergify section injected in classic merge box"); - return true; } - return false; + return injected; } async function _tryInject() { @@ -150,26 +192,21 @@ async function _tryInject() { subpath: _data.subpath, }; - const existingRow = document.querySelector("#mergify"); - if (existingRow) { - updateMergifyRow(existingRow); - void renderMergifyContext(contextPayload); - return; - } - - // Inject the row immediately with state derived synchronously from the - // DOM. The queue-state and stack-context fetches run in parallel in the - // background — when fetchQueueStateIfNeeded resolves, we re-derive the - // button state and swap it in if it changed. - const injected = injectRowIntoMergeBox(); + // injectRowIntoMergeBox is idempotent per anchor (data-attr guard inside) + // so we can call it on every tick — it will only add a row to anchors + // that don't already have one. Existing rows still get their queue state + // refreshed via updateAllMergifyRows below. + injectRowIntoMergeBox(); + updateAllMergifyRows(); void renderMergifyContext(contextPayload); void fetchQueueStateIfNeeded().then(() => { - const row = document.querySelector("#mergify"); - if (row) updateMergifyRow(row); + updateAllMergifyRows(); }); - if (injected) scheduleQueueStatePoll(); + if (document.querySelector(`[${MERGE_BOX_ROW_ATTR}]`)) { + scheduleQueueStatePoll(); + } } function tryInject() { diff --git a/src/queue.js b/src/queue.js index f81d939..6bd76b1 100644 --- a/src/queue.js +++ b/src/queue.js @@ -301,8 +301,7 @@ export function postCommand(command) { export function postCommandAndUpdate(command) { postCommand(command); - const row = document.querySelector("#mergify"); - if (row) updateMergifyRow(row); + updateAllMergifyRows(); } export function buildMergeBoxButton( @@ -412,11 +411,22 @@ export function getMergeQueueLink() { return `https://dashboard.mergify.com/queues?login=${data.org}&repository=${data.repo}&branch=main&pull-request-number=${data.pull}`; } -export function buildMergifyRow() { +// Marker for the rows we inject. Use a data attribute rather than an id so +// the row can coexist multiple times across the legacy merge box and the new +// merge-status sidebar without clashing on uniqueness. Also avoids matching +// GitHub's native
for the Mergify GitHub App check. +export const MERGE_BOX_ROW_ATTR = "data-mergify-merge-box-row"; + +export function buildMergifyRow(variant = "default") { + if (variant === "sidebar") return buildMergifySidebarRow(); + const state = deriveQueueButtonState(); const row = document.createElement("div"); - row.id = "mergify"; + // Variant is stored on the attribute so updateMergifyRow can rebuild + // the row in the same style when state transitions from open to + // merged/closed. + row.setAttribute(MERGE_BOX_ROW_ATTR, "default"); row.className = "bgColor-muted borderColor-muted border-top rounded-bottom-2"; row.style.cssText = @@ -487,14 +497,104 @@ export function buildMergifyRow() { return row; } +function buildMergifySidebarRow() { + const state = deriveQueueButtonState(); + + const row = document.createElement("div"); + row.setAttribute(MERGE_BOX_ROW_ATTR, "sidebar"); + // Two-line layout that matches the surrounding native sections (border + // rules on top and bottom, no muted background); action buttons on line + // one, queue/logs on the left of line two and Mergify brand on the + // right. + row.className = "border-top border-bottom color-border-subtle"; + row.style.cssText = + "display:flex;flex-direction:column;gap:8px;padding:var(--base-size-16,16px)!important;margin-top:var(--base-size-24,24px);"; + + if (state !== "merged" && state !== "closed") { + const buttons = document.createElement("div"); + buttons.style.cssText = "display:flex;gap:6px;"; + buttons.appendChild(buildQueueButton(state)); + for (const btn of BUTTONS) { + buttons.appendChild( + buildMergeBoxButton( + btn.command, + btn.label, + btn.tooltip, + false, + "secondary", + ), + ); + } + row.appendChild(buttons); + } + + const secondLine = document.createElement("div"); + secondLine.style.cssText = + "display:flex;align-items:center;justify-content:space-between;gap:8px;"; + + const links = document.createElement("div"); + links.style.cssText = + "display:flex;align-items:center;gap:12px;font-size:14px;"; + + function appendLink(href, text, iconSvg) { + const link = document.createElement("a"); + link.href = href; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.style.cssText = + "color:var(--fgColor-accent, #58a6ff);text-decoration:none;display:inline-flex;align-items:center;gap:4px;"; + link.appendChild(parseSvg(iconSvg)); + link.appendChild(document.createTextNode(text)); + links.appendChild(link); + } + + appendLink(getMergeQueueLink(), "queue", QUEUE_ICON_SVG); + appendLink(getEventLogLink(), "logs", LOGS_ICON_SVG); + + if (state === "merged") { + const status = document.createElement("span"); + status.style.color = "var(--fgColor-muted, #7d8590)"; + status.textContent = getMergedMessage(); + links.appendChild(status); + } + secondLine.appendChild(links); + + const brand = document.createElement("a"); + brand.href = "https://dashboard.mergify.com"; + brand.target = "_blank"; + brand.rel = "noopener noreferrer"; + brand.title = "Open Mergify dashboard"; + brand.style.cssText = + "display:flex;align-items:center;gap:6px;color:var(--fgColor-default, #e6edf3);text-decoration:none;font-weight:600;"; + const svgEl = parseSvg(getLogoSvg()); + svgEl.setAttribute("width", "20"); + svgEl.setAttribute("height", "20"); + brand.appendChild(svgEl); + const brandText = document.createElement("span"); + brandText.textContent = "Mergify"; + brand.appendChild(brandText); + secondLine.appendChild(brand); + + row.appendChild(secondLine); + + return row; +} + +export function updateAllMergifyRows() { + const rows = document.querySelectorAll(`[${MERGE_BOX_ROW_ATTR}]`); + for (const row of rows) updateMergifyRow(row); +} + export function updateMergifyRow(row) { const state = deriveQueueButtonState(); const oldBtn = row.querySelector("[data-mergify-queue-btn]"); if (state === "merged" || state === "closed") { if (oldBtn) { - // PR was open, now merged/closed — rebuild entire row - const newRow = buildMergifyRow(); + // PR was open, now merged/closed — rebuild entire row, keeping + // the same variant so the sidebar styling survives the swap. + const variant = row.getAttribute(MERGE_BOX_ROW_ATTR) || "default"; + const newRow = buildMergifyRow(variant); row.replaceWith(newRow); debug("Mergify row rebuilt for state:", state); } @@ -515,10 +615,7 @@ export function scheduleQueueStatePoll() { if (!isGitHubPullRequestPage()) return; const previousState = lastKnownQueueState; await fetchQueueState(); - if (previousState !== lastKnownQueueState) { - const row = document.querySelector("#mergify"); - if (row) updateMergifyRow(row); - } + if (previousState !== lastKnownQueueState) updateAllMergifyRows(); if (isGitHubPullRequestPage()) scheduleQueueStatePoll(); }, QUEUE_POLL_INTERVAL); }