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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ where you'd expect it; only the conversation timeline gets reversed.

A small button in the bottom-right of every PR page lets you flip between
**Newest first** and **Oldest first**. Your choice is remembered across
page loads.
page loads. When a PR has status checks, an indicator at the top of the
conversation shows their overall state and jumps to the checks box.

![Toggle button in bottom-right](https://placehold.co/600x40/1f6feb/ffffff?text=%E2%86%93+Newest+first)

Expand Down Expand Up @@ -64,6 +65,9 @@ on github.com and:

- Newest comment is at the top of the conversation.
- The PR description stays where it always was, above the timeline.
- If the PR has status checks, a status indicator near the top shows
their overall state (passing / failing / running). Click it to jump
to the checks box.
- The **↓ Newest first** button in the bottom-right corner toggles the
order. Click it to switch to oldest-first; click again to switch back.
- Your preference is saved automatically and applies to every PR you visit.
Expand Down
66 changes: 66 additions & 0 deletions checks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// PR status-checks detection for the Conversation page (issue #1).
//
// GitHub renders the PR's status checks in the merge box near the BOTTOM
// of the conversation, not in the timeline. The box holds one row per
// check, each with an accessible label like:
// "AutoTest - 2.0 successful in 54m"
// "required/architect-approval waiting for status to be reported"
// "Claude Code / claude (pull_request_review) skipped"
// We locate that box and aggregate the rows into a single coarse status so
// a small indicator can be surfaced at the top of the page and jump to it.
//
// Browser: attaches the helpers to the global scope (loaded before
// content.js). Node: exports them for tests.

(function (root, factory) {
if (typeof module !== "undefined" && module.exports) {
module.exports = factory();
} else {
Object.assign(root, factory());
}
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
// The whole status-checks/merge box — used as the scroll target. Class
// names are CSS-module-hashed (`MergeBox-module__mergePartialContainer__x`)
// so we match on the stable human-readable prefix and fall back through
// a couple of related containers.
function findChecksBox(root) {
const scope = root || document;
return (
scope.querySelector('[class*="MergeBox-module__mergePartialContainer"]') ||
scope.querySelector('[class*="ExpandedChecks-module__checksContainer"]') ||
scope.querySelector('[class*="MergeBoxExpandable-module"]') ||
null
);
}

// The accessible labels of the individual check rows within the box.
function getCheckLabels(root) {
const scope = root || document;
const box = findChecksBox(scope) || scope;
return Array.from(box.querySelectorAll("li[aria-label]"))
.map((li) => (li.getAttribute("aria-label") || "").trim())
.filter(Boolean);
}

// Aggregate the check-row labels into a coarse { key, label, color }.
// Precedence: any failure -> failing; else any in-flight -> running;
// else any success -> passing; else unknown. Checked in that order so a
// single red check dominates the summary, matching GitHub's own rollup.
function deriveChecksState(labels) {
const list = Array.isArray(labels) ? labels : [labels || ""];
const any = (re) => list.some((l) => re.test(l));

if (any(/(fail|error|timed out|cancel|denied|action required)/i)) {
return { key: "failing", label: "✗ Checks failing", color: "#d1242f" };
}
if (any(/(in progress|in_progress|pending|queued|running|waiting|expected)/i)) {
return { key: "running", label: "• Checks running", color: "#9a6700" };
}
if (any(/(success|passed|passing)/i)) {
return { key: "passing", label: "✓ Checks passing", color: "#1a7f37" };
}
return { key: "unknown", label: "• Checks status", color: "#1f6feb" };
}

return { findChecksBox, getCheckLabels, deriveChecksState };
});
79 changes: 79 additions & 0 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
const RESET_VERSION_KEY = "prrcDefaultResetVersion";
const CURRENT_RESET_VERSION = 1;
const BUTTON_ID = "pr-reverse-comments-toggle";
const CHECKS_STATUS_ID = "pr-reverse-comments-checks-status";

// Per-page configuration. `getTargets()` returns an array of
// { el, item, descendant }
Expand Down Expand Up @@ -228,6 +229,80 @@
document.body.appendChild(btn);
}

// Where to put the checks indicator: at the very top of the conversation
// column, above the PR description. We insert *before* one of these
// anchors within its parent.
function getChecksIndicatorAnchor() {
const candidates = [
'[data-testid="issue-viewer-issue-container"] [data-testid="pr-timeline"]',
".js-discussion",
".pull-discussion-timeline",
];
for (const sel of candidates) {
const el = document.querySelector(sel);
if (el && el.parentElement) return el;
}
return null;
}

function scrollToChecksBox() {
const box = findChecksBox();
if (!box) return;
box.scrollIntoView({ behavior: "smooth", block: "center" });
box.style.outline = "2px solid #1f6feb";
box.style.borderRadius = "6px";
setTimeout(() => {
box.style.outline = "";
}, 1500);
}

function injectOrUpdateChecksIndicator() {
const existing = document.getElementById(CHECKS_STATUS_ID);
const cfg = getCurrentPageConfig();
if (!cfg || cfg.name !== "conversation") {
if (existing) existing.remove();
return;
}

const anchor = getChecksIndicatorAnchor();
if (!findChecksBox() || !anchor || !anchor.parentElement) {
if (existing) existing.remove();
return;
}

const state = deriveChecksState(getCheckLabels());
const indicator = existing || document.createElement("button");
if (!existing) {
indicator.id = CHECKS_STATUS_ID;
indicator.type = "button";
indicator.style.cssText = [
"display: inline-block",
"margin: 8px 0 12px 0",
"padding: 6px 10px",
"background: var(--bgColor-muted, #f6f8fa)",
"border-radius: 6px",
"font: 12px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
"cursor: pointer",
].join(";");
indicator.title = "Click to jump to the PR status checks";
indicator.addEventListener("click", scrollToChecksBox);
}

// Only write to the DOM when the status actually changed; otherwise the
// body MutationObserver that calls us would see our own text/style
// mutations and reschedule forever.
if (indicator.dataset.prrcState !== state.key) {
indicator.dataset.prrcState = state.key;
indicator.textContent = state.label;
indicator.style.border = `1px solid ${state.color}`;
indicator.style.color = state.color;
}

if (indicator !== anchor.previousElementSibling) {
anchor.parentElement.insertBefore(indicator, anchor);
}
}

function updateButtonLabel(btn) {
btn.textContent = currentOrder === ORDER.NEWEST ? "↓ Newest first" : "↑ Oldest first";
btn.title = `Click to switch to ${currentOrder === ORDER.NEWEST ? ORDER.OLDEST : ORDER.NEWEST} first`;
Expand Down Expand Up @@ -255,6 +330,8 @@
if (!onSupportedPage()) {
const btn = document.getElementById(BUTTON_ID);
if (btn) btn.remove();
const checks = document.getElementById(CHECKS_STATUS_ID);
if (checks) checks.remove();
disconnectObservers();
activeTargets = [];
return;
Expand All @@ -263,6 +340,7 @@
if (!document.getElementById(BUTTON_ID)) {
injectToggleButton();
}
injectOrUpdateChecksIndicator();

const cfg = getCurrentPageConfig();
const freshTargets = cfg.getTargets();
Expand Down Expand Up @@ -301,6 +379,7 @@
startBodyWatcher();
if (onSupportedPage()) {
injectToggleButton();
injectOrUpdateChecksIndicator();
}
scheduleRebindIfNeeded();
}
Expand Down
5 changes: 4 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const sharedGlobals = {
firstMatchingTarget: "readonly",
pushedCommitTargets: "readonly",
applyOrderToTarget: "readonly",
findChecksBox: "readonly",
getCheckLabels: "readonly",
deriveChecksState: "readonly",
};

export default [
Expand All @@ -20,7 +23,7 @@ export default [
// UMD modules: run in the browser (extension) AND under Node (tests),
// so they legitimately reference both `globalThis`/window and module.
{
files: ["constants.js", "reorder.js"],
files: ["constants.js", "reorder.js", "checks.js"],
languageOptions: {
globals: { ...globals.browser, ...globals.node, ...sharedGlobals },
},
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"content_scripts": [
{
"matches": ["https://github.com/*/*/pull/*"],
"js": ["constants.js", "reorder.js", "content.js"],
"js": ["constants.js", "reorder.js", "checks.js", "content.js"],
"run_at": "document_idle"
}
],
Expand Down
100 changes: 100 additions & 0 deletions test/checks.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from "vitest";
import checks from "../checks.js";

const { findChecksBox, getCheckLabels, deriveChecksState } = checks;

beforeEach(() => {
document.body.innerHTML = "";
});

// Build a merge/checks box (CSS-module-style class) with the given check
// aria-labels, mirroring GitHub's real structure.
function buildChecksBox(labels) {
const box = document.createElement("div");
box.className = "MergeBox-module__mergePartialContainer__MTXP9 border";
const ul = document.createElement("ul");
ul.setAttribute("data-listview-component", "items-list");
for (const label of labels) {
const li = document.createElement("li");
li.setAttribute("aria-label", label);
ul.appendChild(li);
}
box.appendChild(ul);
document.body.appendChild(box);
return box;
}

// The 17 real check labels captured from a live cognito PR.
const REAL_LABELS = [
"AutoTest - 2.0 successful in 54m",
"AutoTest - 2.0 (Build Build Cognito.sln) successful in 5m",
"AutoTest - 2.0 (Run Service Tests Run Service Tests) successful in 35m",
"Claude Code / claude (pull_request_review) skipped",
"Main-CI successful in 3m",
"required/architect-approval waiting for status to be reported",
"required/work-item-link",
];

describe("deriveChecksState", () => {
it("reports passing when all checks succeeded (or skipped)", () => {
const s = deriveChecksState([
"AutoTest successful in 54m",
"Main-CI successful in 3m",
"x skipped",
]);
expect(s.key).toBe("passing");
expect(s.label).toContain("passing");
});

it("reports running when a check is still in flight", () => {
expect(deriveChecksState(["a successful", "b in progress"]).key).toBe("running");
expect(deriveChecksState(["a successful", "b waiting for status to be reported"]).key).toBe(
"running",
);
expect(deriveChecksState(["a pending"]).key).toBe("running");
});

it("reports failing when any check failed", () => {
expect(deriveChecksState(["a successful", "b failing after 2m"]).key).toBe("failing");
expect(deriveChecksState(["a errored"]).key).toBe("failing");
});

it("lets failure win over success and in-flight", () => {
const s = deriveChecksState(["a successful", "b in progress", "c failing"]);
expect(s.key).toBe("failing");
});

it("returns unknown for an empty or statusless set", () => {
expect(deriveChecksState([]).key).toBe("unknown");
expect(deriveChecksState(["required/work-item-link"]).key).toBe("unknown");
});

it("accepts a single string as well as an array", () => {
expect(deriveChecksState("everything successful").key).toBe("passing");
});

it("matches the real captured check set (running: one is waiting)", () => {
expect(deriveChecksState(REAL_LABELS).key).toBe("running");
});
});

describe("findChecksBox / getCheckLabels", () => {
it("finds the merge box by its stable class prefix", () => {
const box = buildChecksBox(["AutoTest successful in 1m"]);
expect(findChecksBox()).toBe(box);
});

it("returns null when there is no checks box", () => {
expect(findChecksBox()).toBeNull();
});

it("collects the trimmed, non-empty check labels", () => {
buildChecksBox(["AutoTest successful in 1m", " Main-CI successful ", ""]);
expect(getCheckLabels()).toEqual(["AutoTest successful in 1m", "Main-CI successful"]);
});

it("end-to-end: real labels in a box derive to running", () => {
buildChecksBox(REAL_LABELS);
expect(deriveChecksState(getCheckLabels()).key).toBe("running");
});
});
Loading