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 = `
+
+
+
+
`;
+
+ 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);
}