diff --git a/README.md b/README.md index e266a3b..806ac24 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/checks.js b/checks.js new file mode 100644 index 0000000..40ab8d0 --- /dev/null +++ b/checks.js @@ -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 }; +}); diff --git a/content.js b/content.js index c14273f..9b3a0df 100644 --- a/content.js +++ b/content.js @@ -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 } @@ -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`; @@ -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; @@ -263,6 +340,7 @@ if (!document.getElementById(BUTTON_ID)) { injectToggleButton(); } + injectOrUpdateChecksIndicator(); const cfg = getCurrentPageConfig(); const freshTargets = cfg.getTargets(); @@ -301,6 +379,7 @@ startBodyWatcher(); if (onSupportedPage()) { injectToggleButton(); + injectOrUpdateChecksIndicator(); } scheduleRebindIfNeeded(); } diff --git a/eslint.config.mjs b/eslint.config.mjs index fa1548c..37fc335 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,9 @@ const sharedGlobals = { firstMatchingTarget: "readonly", pushedCommitTargets: "readonly", applyOrderToTarget: "readonly", + findChecksBox: "readonly", + getCheckLabels: "readonly", + deriveChecksState: "readonly", }; export default [ @@ -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 }, }, diff --git a/manifest.json b/manifest.json index db43199..585e8a3 100644 --- a/manifest.json +++ b/manifest.json @@ -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" } ], diff --git a/test/checks.test.mjs b/test/checks.test.mjs new file mode 100644 index 0000000..23585aa --- /dev/null +++ b/test/checks.test.mjs @@ -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"); + }); +});