From 498d8431cc88ff30da7ddff85534c44c56d928f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:17:50 +0000 Subject: [PATCH 1/3] Initial plan From c6203e3b5df55a473341bfe718188882f66eb6ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:20:24 +0000 Subject: [PATCH 2/3] Add top autotest status indicator on PR conversation pages --- README.md | 5 ++- content.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 300495e..e9cd91c 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. If the timeline contains an `autotest` update, a status +indicator appears near the top of the conversation and jumps to that block. ![Toggle button in bottom-right](https://placehold.co/600x40/1f6feb/ffffff?text=%E2%86%93+Newest+first) @@ -64,6 +65,8 @@ 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 an `autotest` timeline block exists, you'll see a status indicator + near the top. Click it to jump straight to that block. - 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/content.js b/content.js index 8e6b14d..912be94 100644 --- a/content.js +++ b/content.js @@ -16,6 +16,7 @@ const RESET_VERSION_KEY = "prrcDefaultResetVersion"; const CURRENT_RESET_VERSION = 1; const BUTTON_ID = "pr-reverse-comments-toggle"; + const AUTOTEST_STATUS_ID = "pr-reverse-comments-autotest-status"; // Per-page configuration. `getTargets()` returns an array of // { el, item, containerSel } @@ -294,6 +295,95 @@ document.body.appendChild(btn); } + function getAutotestTimelineItem() { + const candidates = [ + '[data-testid="issue-viewer-issue-container"] [data-testid^="issue-viewer-comment"]', + '[data-testid="pr-timeline"] [data-testid^="pr-timeline-item"]', + ".js-discussion .js-timeline-item", + ".pull-discussion-timeline .js-timeline-item", + ]; + for (const sel of candidates) { + for (const el of document.querySelectorAll(sel)) { + if ((el.textContent || "").toLowerCase().includes("autotest")) return el; + } + } + return null; + } + + function getAutotestState(text) { + if (/(fail|error|timed out|cancelled|canceled)/i.test(text)) { + return { label: "✗ Autotest failing", color: "#da3633" }; + } + if (/(pass|success|succeed)/i.test(text)) { + return { label: "✓ Autotest passing", color: "#238636" }; + } + if (/(pending|in progress|queued|running)/i.test(text)) { + return { label: "• Autotest running", color: "#9a6700" }; + } + return { label: "• Autotest status", color: "#1f6feb" }; + } + + function getAutotestInsertBeforeNode() { + 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 injectOrUpdateAutotestIndicator() { + const existing = document.getElementById(AUTOTEST_STATUS_ID); + const cfg = getCurrentPageConfig(); + if (!cfg || cfg.name !== "conversation") { + if (existing) existing.remove(); + return; + } + + const target = getAutotestTimelineItem(); + const insertBefore = getAutotestInsertBeforeNode(); + if (!target || !insertBefore || !insertBefore.parentElement) { + if (existing) existing.remove(); + return; + } + + const state = getAutotestState(target.textContent || ""); + const indicator = existing || document.createElement("button"); + if (!existing) { + indicator.id = AUTOTEST_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.addEventListener("click", () => { + const freshTarget = getAutotestTimelineItem(); + if (!freshTarget) return; + freshTarget.scrollIntoView({ behavior: "smooth", block: "center" }); + freshTarget.style.outline = "2px solid #1f6feb"; + setTimeout(() => { freshTarget.style.outline = ""; }, 1200); + }); + } + + indicator.textContent = state.label; + indicator.title = "Click to jump to autotest status in the timeline"; + indicator.style.border = `1px solid ${state.color}`; + indicator.style.color = state.color; + + if (indicator !== insertBefore.previousElementSibling) { + insertBefore.parentElement.insertBefore(indicator, insertBefore); + } + } + function updateButtonLabel(btn) { btn.textContent = currentOrder === "newest" ? "↓ Newest first" : "↑ Oldest first"; btn.title = `Click to switch to ${currentOrder === "newest" ? "oldest" : "newest"} first`; @@ -321,6 +411,8 @@ if (!onSupportedPage()) { const btn = document.getElementById(BUTTON_ID); if (btn) btn.remove(); + const autotest = document.getElementById(AUTOTEST_STATUS_ID); + if (autotest) autotest.remove(); disconnectObservers(); activeTargets = []; return; @@ -329,6 +421,7 @@ if (!document.getElementById(BUTTON_ID)) { injectToggleButton(); } + injectOrUpdateAutotestIndicator(); const cfg = getCurrentPageConfig(); const freshTargets = cfg.getTargets(); @@ -367,6 +460,7 @@ startBodyWatcher(); if (onSupportedPage()) { injectToggleButton(); + injectOrUpdateAutotestIndicator(); } scheduleRebindIfNeeded(); } From 73026f43f3e9155b34a694521f3d89b40c64e9dd Mon Sep 17 00:00:00 2001 From: Julius Walton Date: Thu, 4 Jun 2026 00:30:43 -0400 Subject: [PATCH 3/3] Target the PR status-checks box instead of the timeline The original implementation searched timeline items for the word "autotest"; verified against a real PR's DOM, that finds nothing because the checks live in the merge box (a sibling of the timeline), so the indicator never rendered. Replace it with checks.js, which locates the merge/checks box and aggregates the per-check aria-labels into one overall passing/failing/running state, and have the indicator scroll to that box. Also guards against a mutation-observer feedback loop by only writing to the DOM when the status key changes. Covered by 11 unit tests and validated against the captured DOM (17 checks -> running). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 9 ++-- checks.js | 66 ++++++++++++++++++++++++++++ content.js | 93 +++++++++++++++++----------------------- eslint.config.mjs | 5 ++- manifest.json | 2 +- test/checks.test.mjs | 100 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 60 deletions(-) create mode 100644 checks.js create mode 100644 test/checks.test.mjs diff --git a/README.md b/README.md index e90c394..806ac24 100644 --- a/README.md +++ b/README.md @@ -9,8 +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. If the timeline contains an `autotest` update, a status -indicator appears near the top of the conversation and jumps to that block. +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) @@ -65,8 +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 an `autotest` timeline block exists, you'll see a status indicator - near the top. Click it to jump straight to that block. +- 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 43e7fc3..9b3a0df 100644 --- a/content.js +++ b/content.js @@ -17,7 +17,7 @@ const RESET_VERSION_KEY = "prrcDefaultResetVersion"; const CURRENT_RESET_VERSION = 1; const BUTTON_ID = "pr-reverse-comments-toggle"; - const AUTOTEST_STATUS_ID = "pr-reverse-comments-autotest-status"; + const CHECKS_STATUS_ID = "pr-reverse-comments-checks-status"; // Per-page configuration. `getTargets()` returns an array of // { el, item, descendant } @@ -229,35 +229,10 @@ document.body.appendChild(btn); } - function getAutotestTimelineItem() { - const candidates = [ - '[data-testid="issue-viewer-issue-container"] [data-testid^="issue-viewer-comment"]', - '[data-testid="pr-timeline"] [data-testid^="pr-timeline-item"]', - ".js-discussion .js-timeline-item", - ".pull-discussion-timeline .js-timeline-item", - ]; - for (const sel of candidates) { - for (const el of document.querySelectorAll(sel)) { - if ((el.textContent || "").toLowerCase().includes("autotest")) return el; - } - } - return null; - } - - function getAutotestState(text) { - if (/(fail|error|timed out|cancelled|canceled)/i.test(text)) { - return { label: "✗ Autotest failing", color: "#da3633" }; - } - if (/(pass|success|succeed)/i.test(text)) { - return { label: "✓ Autotest passing", color: "#238636" }; - } - if (/(pending|in progress|queued|running)/i.test(text)) { - return { label: "• Autotest running", color: "#9a6700" }; - } - return { label: "• Autotest status", color: "#1f6feb" }; - } - - function getAutotestInsertBeforeNode() { + // 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", @@ -270,25 +245,35 @@ return null; } - function injectOrUpdateAutotestIndicator() { - const existing = document.getElementById(AUTOTEST_STATUS_ID); + 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 target = getAutotestTimelineItem(); - const insertBefore = getAutotestInsertBeforeNode(); - if (!target || !insertBefore || !insertBefore.parentElement) { + const anchor = getChecksIndicatorAnchor(); + if (!findChecksBox() || !anchor || !anchor.parentElement) { if (existing) existing.remove(); return; } - const state = getAutotestState(target.textContent || ""); + const state = deriveChecksState(getCheckLabels()); const indicator = existing || document.createElement("button"); if (!existing) { - indicator.id = AUTOTEST_STATUS_ID; + indicator.id = CHECKS_STATUS_ID; indicator.type = "button"; indicator.style.cssText = [ "display: inline-block", @@ -299,22 +284,22 @@ "font: 12px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", "cursor: pointer", ].join(";"); - indicator.addEventListener("click", () => { - const freshTarget = getAutotestTimelineItem(); - if (!freshTarget) return; - freshTarget.scrollIntoView({ behavior: "smooth", block: "center" }); - freshTarget.style.outline = "2px solid #1f6feb"; - setTimeout(() => { freshTarget.style.outline = ""; }, 1200); - }); + indicator.title = "Click to jump to the PR status checks"; + indicator.addEventListener("click", scrollToChecksBox); } - indicator.textContent = state.label; - indicator.title = "Click to jump to autotest status in the timeline"; - indicator.style.border = `1px solid ${state.color}`; - indicator.style.color = state.color; + // 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 !== insertBefore.previousElementSibling) { - insertBefore.parentElement.insertBefore(indicator, insertBefore); + if (indicator !== anchor.previousElementSibling) { + anchor.parentElement.insertBefore(indicator, anchor); } } @@ -345,8 +330,8 @@ if (!onSupportedPage()) { const btn = document.getElementById(BUTTON_ID); if (btn) btn.remove(); - const autotest = document.getElementById(AUTOTEST_STATUS_ID); - if (autotest) autotest.remove(); + const checks = document.getElementById(CHECKS_STATUS_ID); + if (checks) checks.remove(); disconnectObservers(); activeTargets = []; return; @@ -355,7 +340,7 @@ if (!document.getElementById(BUTTON_ID)) { injectToggleButton(); } - injectOrUpdateAutotestIndicator(); + injectOrUpdateChecksIndicator(); const cfg = getCurrentPageConfig(); const freshTargets = cfg.getTargets(); @@ -394,7 +379,7 @@ startBodyWatcher(); if (onSupportedPage()) { injectToggleButton(); - injectOrUpdateAutotestIndicator(); + 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"); + }); +});