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
210 changes: 210 additions & 0 deletions src/__tests__/mergify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {
PrStatusCache,
StackContextCache,
findTimelineActions,
injectRowIntoMergeBox,
isPullRequestOpen,
isPullRequestQueued,
resetQueueState,
Expand Down Expand Up @@ -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 <div role="dialog">,
// not under [data-testid="mergebox-partial"].
document.body.innerHTML = `
<div role="dialog" aria-modal="true">
<div class="MergeBox-module__mergePartialContainer__MTXP9">
<div data-testid="mergebox-border-container">
<section aria-label="Checks"></section>
<section aria-label="Draft state"></section>
</div>
</div>
</div>`;

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 <section id="mergify"> sub-section', () => {
document.body.innerHTML = `
<div data-testid="mergebox-border-container">
<section id="mergify" aria-label="Mergify">native app section</section>
<section aria-label="Checks"></section>
</div>`;

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 = `
<div data-testid="mergebox-partial">
<div class="border rounded-2 borderColor-default">
<section aria-label="Checks"></section>
</div>
</div>`;

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 = "<div></div>";

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 = `
<div role="dialog" aria-modal="true">
<div data-testid="mergebox-border-container">
<section aria-label="Checks"></section>
</div>
</div>
<div data-testid="mergebox-partial">
<div class="border rounded-2 borderColor-default">
<section aria-label="Checks"></section>
</div>
</div>
<div class="mergeability-details"></div>`;

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 = `
<div data-testid="mergebox-border-container">
<section aria-label="Checks"></section>
</div>`;

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 = `
<div data-testid="mergebox-partial">
<div data-testid="mergebox-border-container">
<section aria-label="Checks"></section>
</div>
</div>`;

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 = `
<div role="dialog" aria-modal="true">
<div data-testid="mergebox-border-container">
<section aria-label="Checks"></section>
</div>
</div>
<div data-testid="mergebox-partial">
<div class="border rounded-2 borderColor-default">
<section aria-label="Checks"></section>
</div>
</div>`;

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 = "";
Expand Down
143 changes: 90 additions & 53 deletions src/mergify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
Loading