Skip to content

Commit 73026f4

Browse files
ShiosOSclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 1395c29 commit 73026f4

6 files changed

Lines changed: 215 additions & 60 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ where you'd expect it; only the conversation timeline gets reversed.
99

1010
A small button in the bottom-right of every PR page lets you flip between
1111
**Newest first** and **Oldest first**. Your choice is remembered across
12-
page loads. If the timeline contains an `autotest` update, a status
13-
indicator appears near the top of the conversation and jumps to that block.
12+
page loads. When a PR has status checks, an indicator at the top of the
13+
conversation shows their overall state and jumps to the checks box.
1414

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

@@ -65,8 +65,9 @@ on github.com and:
6565

6666
- Newest comment is at the top of the conversation.
6767
- The PR description stays where it always was, above the timeline.
68-
- If an `autotest` timeline block exists, you'll see a status indicator
69-
near the top. Click it to jump straight to that block.
68+
- If the PR has status checks, a status indicator near the top shows
69+
their overall state (passing / failing / running). Click it to jump
70+
to the checks box.
7071
- The **↓ Newest first** button in the bottom-right corner toggles the
7172
order. Click it to switch to oldest-first; click again to switch back.
7273
- Your preference is saved automatically and applies to every PR you visit.

checks.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// PR status-checks detection for the Conversation page (issue #1).
2+
//
3+
// GitHub renders the PR's status checks in the merge box near the BOTTOM
4+
// of the conversation, not in the timeline. The box holds one row per
5+
// check, each with an accessible label like:
6+
// "AutoTest - 2.0 successful in 54m"
7+
// "required/architect-approval waiting for status to be reported"
8+
// "Claude Code / claude (pull_request_review) skipped"
9+
// We locate that box and aggregate the rows into a single coarse status so
10+
// a small indicator can be surfaced at the top of the page and jump to it.
11+
//
12+
// Browser: attaches the helpers to the global scope (loaded before
13+
// content.js). Node: exports them for tests.
14+
15+
(function (root, factory) {
16+
if (typeof module !== "undefined" && module.exports) {
17+
module.exports = factory();
18+
} else {
19+
Object.assign(root, factory());
20+
}
21+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
22+
// The whole status-checks/merge box — used as the scroll target. Class
23+
// names are CSS-module-hashed (`MergeBox-module__mergePartialContainer__x`)
24+
// so we match on the stable human-readable prefix and fall back through
25+
// a couple of related containers.
26+
function findChecksBox(root) {
27+
const scope = root || document;
28+
return (
29+
scope.querySelector('[class*="MergeBox-module__mergePartialContainer"]') ||
30+
scope.querySelector('[class*="ExpandedChecks-module__checksContainer"]') ||
31+
scope.querySelector('[class*="MergeBoxExpandable-module"]') ||
32+
null
33+
);
34+
}
35+
36+
// The accessible labels of the individual check rows within the box.
37+
function getCheckLabels(root) {
38+
const scope = root || document;
39+
const box = findChecksBox(scope) || scope;
40+
return Array.from(box.querySelectorAll("li[aria-label]"))
41+
.map((li) => (li.getAttribute("aria-label") || "").trim())
42+
.filter(Boolean);
43+
}
44+
45+
// Aggregate the check-row labels into a coarse { key, label, color }.
46+
// Precedence: any failure -> failing; else any in-flight -> running;
47+
// else any success -> passing; else unknown. Checked in that order so a
48+
// single red check dominates the summary, matching GitHub's own rollup.
49+
function deriveChecksState(labels) {
50+
const list = Array.isArray(labels) ? labels : [labels || ""];
51+
const any = (re) => list.some((l) => re.test(l));
52+
53+
if (any(/(fail|error|timed out|cancel|denied|action required)/i)) {
54+
return { key: "failing", label: "✗ Checks failing", color: "#d1242f" };
55+
}
56+
if (any(/(in progress|in_progress|pending|queued|running|waiting|expected)/i)) {
57+
return { key: "running", label: "• Checks running", color: "#9a6700" };
58+
}
59+
if (any(/(success|passed|passing)/i)) {
60+
return { key: "passing", label: "✓ Checks passing", color: "#1a7f37" };
61+
}
62+
return { key: "unknown", label: "• Checks status", color: "#1f6feb" };
63+
}
64+
65+
return { findChecksBox, getCheckLabels, deriveChecksState };
66+
});

content.js

Lines changed: 39 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
const RESET_VERSION_KEY = "prrcDefaultResetVersion";
1818
const CURRENT_RESET_VERSION = 1;
1919
const BUTTON_ID = "pr-reverse-comments-toggle";
20-
const AUTOTEST_STATUS_ID = "pr-reverse-comments-autotest-status";
20+
const CHECKS_STATUS_ID = "pr-reverse-comments-checks-status";
2121

2222
// Per-page configuration. `getTargets()` returns an array of
2323
// { el, item, descendant }
@@ -229,35 +229,10 @@
229229
document.body.appendChild(btn);
230230
}
231231

232-
function getAutotestTimelineItem() {
233-
const candidates = [
234-
'[data-testid="issue-viewer-issue-container"] [data-testid^="issue-viewer-comment"]',
235-
'[data-testid="pr-timeline"] [data-testid^="pr-timeline-item"]',
236-
".js-discussion .js-timeline-item",
237-
".pull-discussion-timeline .js-timeline-item",
238-
];
239-
for (const sel of candidates) {
240-
for (const el of document.querySelectorAll(sel)) {
241-
if ((el.textContent || "").toLowerCase().includes("autotest")) return el;
242-
}
243-
}
244-
return null;
245-
}
246-
247-
function getAutotestState(text) {
248-
if (/(fail|error|timed out|cancelled|canceled)/i.test(text)) {
249-
return { label: "✗ Autotest failing", color: "#da3633" };
250-
}
251-
if (/(pass|success|succeed)/i.test(text)) {
252-
return { label: "✓ Autotest passing", color: "#238636" };
253-
}
254-
if (/(pending|in progress|queued|running)/i.test(text)) {
255-
return { label: "• Autotest running", color: "#9a6700" };
256-
}
257-
return { label: "• Autotest status", color: "#1f6feb" };
258-
}
259-
260-
function getAutotestInsertBeforeNode() {
232+
// Where to put the checks indicator: at the very top of the conversation
233+
// column, above the PR description. We insert *before* one of these
234+
// anchors within its parent.
235+
function getChecksIndicatorAnchor() {
261236
const candidates = [
262237
'[data-testid="issue-viewer-issue-container"] [data-testid="pr-timeline"]',
263238
".js-discussion",
@@ -270,25 +245,35 @@
270245
return null;
271246
}
272247

273-
function injectOrUpdateAutotestIndicator() {
274-
const existing = document.getElementById(AUTOTEST_STATUS_ID);
248+
function scrollToChecksBox() {
249+
const box = findChecksBox();
250+
if (!box) return;
251+
box.scrollIntoView({ behavior: "smooth", block: "center" });
252+
box.style.outline = "2px solid #1f6feb";
253+
box.style.borderRadius = "6px";
254+
setTimeout(() => {
255+
box.style.outline = "";
256+
}, 1500);
257+
}
258+
259+
function injectOrUpdateChecksIndicator() {
260+
const existing = document.getElementById(CHECKS_STATUS_ID);
275261
const cfg = getCurrentPageConfig();
276262
if (!cfg || cfg.name !== "conversation") {
277263
if (existing) existing.remove();
278264
return;
279265
}
280266

281-
const target = getAutotestTimelineItem();
282-
const insertBefore = getAutotestInsertBeforeNode();
283-
if (!target || !insertBefore || !insertBefore.parentElement) {
267+
const anchor = getChecksIndicatorAnchor();
268+
if (!findChecksBox() || !anchor || !anchor.parentElement) {
284269
if (existing) existing.remove();
285270
return;
286271
}
287272

288-
const state = getAutotestState(target.textContent || "");
273+
const state = deriveChecksState(getCheckLabels());
289274
const indicator = existing || document.createElement("button");
290275
if (!existing) {
291-
indicator.id = AUTOTEST_STATUS_ID;
276+
indicator.id = CHECKS_STATUS_ID;
292277
indicator.type = "button";
293278
indicator.style.cssText = [
294279
"display: inline-block",
@@ -299,22 +284,22 @@
299284
"font: 12px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
300285
"cursor: pointer",
301286
].join(";");
302-
indicator.addEventListener("click", () => {
303-
const freshTarget = getAutotestTimelineItem();
304-
if (!freshTarget) return;
305-
freshTarget.scrollIntoView({ behavior: "smooth", block: "center" });
306-
freshTarget.style.outline = "2px solid #1f6feb";
307-
setTimeout(() => { freshTarget.style.outline = ""; }, 1200);
308-
});
287+
indicator.title = "Click to jump to the PR status checks";
288+
indicator.addEventListener("click", scrollToChecksBox);
309289
}
310290

311-
indicator.textContent = state.label;
312-
indicator.title = "Click to jump to autotest status in the timeline";
313-
indicator.style.border = `1px solid ${state.color}`;
314-
indicator.style.color = state.color;
291+
// Only write to the DOM when the status actually changed; otherwise the
292+
// body MutationObserver that calls us would see our own text/style
293+
// mutations and reschedule forever.
294+
if (indicator.dataset.prrcState !== state.key) {
295+
indicator.dataset.prrcState = state.key;
296+
indicator.textContent = state.label;
297+
indicator.style.border = `1px solid ${state.color}`;
298+
indicator.style.color = state.color;
299+
}
315300

316-
if (indicator !== insertBefore.previousElementSibling) {
317-
insertBefore.parentElement.insertBefore(indicator, insertBefore);
301+
if (indicator !== anchor.previousElementSibling) {
302+
anchor.parentElement.insertBefore(indicator, anchor);
318303
}
319304
}
320305

@@ -345,8 +330,8 @@
345330
if (!onSupportedPage()) {
346331
const btn = document.getElementById(BUTTON_ID);
347332
if (btn) btn.remove();
348-
const autotest = document.getElementById(AUTOTEST_STATUS_ID);
349-
if (autotest) autotest.remove();
333+
const checks = document.getElementById(CHECKS_STATUS_ID);
334+
if (checks) checks.remove();
350335
disconnectObservers();
351336
activeTargets = [];
352337
return;
@@ -355,7 +340,7 @@
355340
if (!document.getElementById(BUTTON_ID)) {
356341
injectToggleButton();
357342
}
358-
injectOrUpdateAutotestIndicator();
343+
injectOrUpdateChecksIndicator();
359344

360345
const cfg = getCurrentPageConfig();
361346
const freshTargets = cfg.getTargets();
@@ -394,7 +379,7 @@
394379
startBodyWatcher();
395380
if (onSupportedPage()) {
396381
injectToggleButton();
397-
injectOrUpdateAutotestIndicator();
382+
injectOrUpdateChecksIndicator();
398383
}
399384
scheduleRebindIfNeeded();
400385
}

eslint.config.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const sharedGlobals = {
1010
firstMatchingTarget: "readonly",
1111
pushedCommitTargets: "readonly",
1212
applyOrderToTarget: "readonly",
13+
findChecksBox: "readonly",
14+
getCheckLabels: "readonly",
15+
deriveChecksState: "readonly",
1316
};
1417

1518
export default [
@@ -20,7 +23,7 @@ export default [
2023
// UMD modules: run in the browser (extension) AND under Node (tests),
2124
// so they legitimately reference both `globalThis`/window and module.
2225
{
23-
files: ["constants.js", "reorder.js"],
26+
files: ["constants.js", "reorder.js", "checks.js"],
2427
languageOptions: {
2528
globals: { ...globals.browser, ...globals.node, ...sharedGlobals },
2629
},

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"content_scripts": [
2626
{
2727
"matches": ["https://github.com/*/*/pull/*"],
28-
"js": ["constants.js", "reorder.js", "content.js"],
28+
"js": ["constants.js", "reorder.js", "checks.js", "content.js"],
2929
"run_at": "document_idle"
3030
}
3131
],

test/checks.test.mjs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import checks from "../checks.js";
3+
4+
const { findChecksBox, getCheckLabels, deriveChecksState } = checks;
5+
6+
beforeEach(() => {
7+
document.body.innerHTML = "";
8+
});
9+
10+
// Build a merge/checks box (CSS-module-style class) with the given check
11+
// aria-labels, mirroring GitHub's real structure.
12+
function buildChecksBox(labels) {
13+
const box = document.createElement("div");
14+
box.className = "MergeBox-module__mergePartialContainer__MTXP9 border";
15+
const ul = document.createElement("ul");
16+
ul.setAttribute("data-listview-component", "items-list");
17+
for (const label of labels) {
18+
const li = document.createElement("li");
19+
li.setAttribute("aria-label", label);
20+
ul.appendChild(li);
21+
}
22+
box.appendChild(ul);
23+
document.body.appendChild(box);
24+
return box;
25+
}
26+
27+
// The 17 real check labels captured from a live cognito PR.
28+
const REAL_LABELS = [
29+
"AutoTest - 2.0 successful in 54m",
30+
"AutoTest - 2.0 (Build Build Cognito.sln) successful in 5m",
31+
"AutoTest - 2.0 (Run Service Tests Run Service Tests) successful in 35m",
32+
"Claude Code / claude (pull_request_review) skipped",
33+
"Main-CI successful in 3m",
34+
"required/architect-approval waiting for status to be reported",
35+
"required/work-item-link",
36+
];
37+
38+
describe("deriveChecksState", () => {
39+
it("reports passing when all checks succeeded (or skipped)", () => {
40+
const s = deriveChecksState([
41+
"AutoTest successful in 54m",
42+
"Main-CI successful in 3m",
43+
"x skipped",
44+
]);
45+
expect(s.key).toBe("passing");
46+
expect(s.label).toContain("passing");
47+
});
48+
49+
it("reports running when a check is still in flight", () => {
50+
expect(deriveChecksState(["a successful", "b in progress"]).key).toBe("running");
51+
expect(deriveChecksState(["a successful", "b waiting for status to be reported"]).key).toBe(
52+
"running",
53+
);
54+
expect(deriveChecksState(["a pending"]).key).toBe("running");
55+
});
56+
57+
it("reports failing when any check failed", () => {
58+
expect(deriveChecksState(["a successful", "b failing after 2m"]).key).toBe("failing");
59+
expect(deriveChecksState(["a errored"]).key).toBe("failing");
60+
});
61+
62+
it("lets failure win over success and in-flight", () => {
63+
const s = deriveChecksState(["a successful", "b in progress", "c failing"]);
64+
expect(s.key).toBe("failing");
65+
});
66+
67+
it("returns unknown for an empty or statusless set", () => {
68+
expect(deriveChecksState([]).key).toBe("unknown");
69+
expect(deriveChecksState(["required/work-item-link"]).key).toBe("unknown");
70+
});
71+
72+
it("accepts a single string as well as an array", () => {
73+
expect(deriveChecksState("everything successful").key).toBe("passing");
74+
});
75+
76+
it("matches the real captured check set (running: one is waiting)", () => {
77+
expect(deriveChecksState(REAL_LABELS).key).toBe("running");
78+
});
79+
});
80+
81+
describe("findChecksBox / getCheckLabels", () => {
82+
it("finds the merge box by its stable class prefix", () => {
83+
const box = buildChecksBox(["AutoTest successful in 1m"]);
84+
expect(findChecksBox()).toBe(box);
85+
});
86+
87+
it("returns null when there is no checks box", () => {
88+
expect(findChecksBox()).toBeNull();
89+
});
90+
91+
it("collects the trimmed, non-empty check labels", () => {
92+
buildChecksBox(["AutoTest successful in 1m", " Main-CI successful ", ""]);
93+
expect(getCheckLabels()).toEqual(["AutoTest successful in 1m", "Main-CI successful"]);
94+
});
95+
96+
it("end-to-end: real labels in a box derive to running", () => {
97+
buildChecksBox(REAL_LABELS);
98+
expect(deriveChecksState(getCheckLabels()).key).toBe("running");
99+
});
100+
});

0 commit comments

Comments
 (0)