Skip to content

Instantly share code, notes, and snippets.

@gibson042
Last active January 3, 2025 20:08
Show Gist options
  • Save gibson042/d8ab425d133400959bc5bb875898a97b to your computer and use it in GitHub Desktop.
Save gibson042/d8ab425d133400959bc5bb875898a97b to your computer and use it in GitHub Desktop.
GitHub Fixer user script
// ==UserScript==
// @name GitHub Fixer
// @namespace https://github.com/gibson042
// @description Sticky pull request tabs, filter CI checks by status, expand all toggles on <{Alt,Option}+Shift+E>.
// @source https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b
// @updateURL https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b/raw/github-fixer.user.js
// @downloadURL https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b/raw/github-fixer.user.js
// @version 0.6.1
// @date 2025-01-03
// @author Richard Gibson <@gmail.com>
// @include https://github.com/*
// ==/UserScript==
//
// **COPYRIGHT NOTICE**
//
// To the extent possible under law, the author(s) have dedicated all copyright
// and related and neighboring rights to this software to the public domain
// worldwide. This software is distributed without any warranty.
// For the CC0 Public Domain Dedication, see
// <https://creativecommons.org/publicdomain/zero/1.0/>.
//
// **END COPYRIGHT NOTICE**
//
//
// Changelog:
// 0.6.0 (2025-01-02)
// * New: <{Alt,Option}+Shift+E> expands all comments/files/etc.
// 0.5.0 (2024-12-30)
// * Fixed: Support "the new merge experience".
// 0.4.6 (2024-12-12)
// * Improved: Use better styles for the repo navigation menu when it is stuck.
// 0.4.0 (2024-12-04)
// * New: Add the small-screen repo navigation menu to the sticky header for mid-page access.
// 0.3.2 (2024-12-03)
// * Improved: Better align colors with the GitHub theme and user color scheme preferences.
// 0.3.0 (2024-10-16)
// * New: Reconstitute pull request titles that ellipsis-split a single commit's subject.
// 0.2.1 (2024-09-10)
// * Improved: Make CI check status filter controls more accessible.
// 0.2.0 (2024-09-10)
// * Fixed: Manually differentiate "cancelled" vs. "action required" CI checks.
// 0.1.3 (2024-04-18)
// * Improved: Support "action required" CI checks (and note inability to support "cancelled" vs. "failing").
// 0.1.2 (2024-03-12)
// * Fixed: Support GitHub's dynamic content replacement.
// 0.1.1 (2024-03-11)
// * Fixed: Differentiate "queued" vs. "expected" CI checks.
// 0.1.0 (2024-03-08)
// * New: Support CI check filtering by status.
// 0.0.1 (2023-09-08)
// * original release
(function fix() {
"use strict";
const ID = "gibson042-github-fixer";
const log = console.log.bind(console, `[${ID}]`);
const warn = console.warn.bind(console, `[${ID}]`);
const error = console.error.bind(console, `[${ID}]`);
const BUTTON_SELECTOR = "button, input[type=button], [role=button]";
const CLOSED_TOGGLE_SELECTOR = `:is(${[
".Details-content--closed",
".discussion-item-toggle-closed",
".outdated-comment:not(.open) .show-outdated-button",
".js-details-container.js-file:not(.open) button.js-details-target",
].join(", ")}):not(details[open] > summary, details[open] > summary *)`;
const FILTER_CLASS = `${ID}-status-filter`;
const STATUSES = ["successful", "failing", "cancelled", "skipped", "action required", "queued", "expected", "in progress"];
const STATUS_SEP = "::";
const STABLE_CONTAINER_SELECTOR = `:is(${[
".merge-pr.is-merging",
/* classic <=2024-12 */ ":has(> .pull-merging)",
].join(", ")})`; // "stable" as in "not dynamically replaced"
const CHECK_CONTAINER_SELECTOR = `:is(${[
"section[aria-label=Checks]",
/* classic <=2024-12 */ ".mergeability-details",
].join(", ")})`;
const CHECK_SUMMARY_SELECTOR = `:is(${[
`${CHECK_CONTAINER_SELECTOR} > [class*='MergeBoxSectionHeader-']`,
/* classic <=2024-12 */ `${CHECK_CONTAINER_SELECTOR} .status-meta`,
].join(", ")})`;
// As of 2024-09-10, "cancelled" and "action required" cannot be distinguished by CSS alone.
const CANCELLED_OR_ACTION_REQUIRED_SELECTOR = ":has(.merge-status-icon > .octicon-stop.neutral-check)";
const HIDDEN_STATUSES_ATTR = `data-${ID}-hidden-statuses`;
const HIDDEN_STATUSES_PROP = HIDDEN_STATUSES_ATTR.replace(/^data-|-([a-z])/g, (_, letter) => letter?.toUpperCase() || "");
const dataProperty = Object.freeze({ configurable: true, enumerable: false, writable: true });
const defineName = (name, fn) => Object.defineProperty(fn, "name", { value: name });
const withResolvers = () => {
let resolve, reject;
const promise = new Promise((...args) => ({ 0: resolve, 1: reject } = args));
return { promise, resolve, reject };
};
const sink = () => {};
const ciCheckSelector = ({ ifHiddenStatus } = {}) => {
const stableContainerFilter = ifHiddenStatus
? `[${HIDDEN_STATUSES_ATTR}*='::${ifHiddenStatus}::']`
: "";
const stableContainerSelector = `${STABLE_CONTAINER_SELECTOR}${stableContainerFilter}`;
return `:is(${[
`${stableContainerSelector} ${CHECK_CONTAINER_SELECTOR} [class*='ExpandedChecks-module__checksContainer'] > *`,
/* classic <=2024-12 */ `${stableContainerSelector} ${CHECK_CONTAINER_SELECTOR} .merge-status-list .merge-status-item`,
].join(", ")})`;
};
// Reconstitute pull request titles that ellipsis-split a single commit's subject.
const elPRTitle = document.querySelector("#new_pull_request input#pull_request_title");
if (elPRTitle?.value.endsWith("…")) {
const elPRBody = document.querySelector("#new_pull_request #pull_request_body");
const mTail = /^…(.*)\n*/.exec(elPRBody?.value);
if (mTail) {
elPRTitle.value = elPRTitle.value.slice(0, -1) + mTail[1];
elPRBody.value = elPRBody.value.slice(mTail[0].length);
}
}
// Add toggle affordances to CI check statuses in the summary,
// tracking hidden statuses as delimiter-separated values in a data property on a containing element.
const containers = new WeakSet();
forEachMutation(document.body, { subtree: true, childList: true, characterData: true }, mutation => {
const nodes = mutation.type === "childList" ? mutation.addedNodes : [mutation.target.parentNode];
for (const item of findMatches(nodes, `${ciCheckSelector()}${CANCELLED_OR_ACTION_REQUIRED_SELECTOR}`)) {
item.classList.toggle(`${ID}-action-required`, !/\bCancelled\b/i.test(item.textContent));
}
for (const elSummary of findMatches(nodes, CHECK_SUMMARY_SELECTOR)) {
const elContainer = elSummary.closest(CHECK_CONTAINER_SELECTOR);
const elStableContainer = elContainer?.closest(STABLE_CONTAINER_SELECTOR);
if (!elContainer) {
warn(`no ${JSON.stringify(CHECK_CONTAINER_SELECTOR)} container`, elSummary);
continue;
} else if (!elStableContainer) {
warn(`no ${JSON.stringify(STABLE_CONTAINER_SELECTOR)} container`, elSummary);
continue;
}
if (!containers.has(elContainer)) {
// Register this new container.
containers.add(elContainer);
elContainer.addEventListener("click", evt => {
const { target } = evt;
if (!target?.matches?.(`${CHECK_SUMMARY_SELECTOR} .${FILTER_CLASS}`)) return;
const elContainer = target.closest(CHECK_CONTAINER_SELECTOR);
const elStableContainer = elContainer.closest(STABLE_CONTAINER_SELECTOR);
const hidden = new Set((elStableContainer.dataset[HIDDEN_STATUSES_PROP] || "").split(STATUS_SEP).filter(val => !!val));
// If checked, the corresponding status is *not* hidden.
const checked = target.ariaChecked === "true" ? false : true;
target.ariaChecked = `${checked}`;
if (checked) {
hidden.delete(target.dataset.status);
} else {
hidden.add(target.dataset.status);
}
elStableContainer.dataset[HIDDEN_STATUSES_PROP] = ["", ...hidden, ""].join(STATUS_SEP);
});
}
// Skip elements that we've already addressed.
if (elSummary.querySelector(`.${FILTER_CLASS}`)) continue;
const hidden = new Set((elStableContainer.dataset[HIDDEN_STATUSES_PROP] || "").split(STATUS_SEP));
// To preserve GitHub's own element reference, don't overwrite <button> HTML.
const selReplaceable = ":scope:not(:has(button)), :scope:has(button) > *:not(button, :has(button))";
for (const el of findMatches([elSummary], selReplaceable)) {
el.innerHTML = el.innerHTML.replace(
RegExp(`[0-9]+\\s+(${STATUSES.join("|")})`, "g"),
(text, status) => {
return [
`<button class="${FILTER_CLASS}" tabindex="0" title="toggle visibility"`,
`data-status="${status}" role="checkbox" aria-checked=${!hidden.has(status)}>${text}</button>`,
].join(" ");
},
);
}
}
});
document.head.insertAdjacentHTML("beforeend", `<style type="text/css">
/* Expose repository links over a sticky header. */
button[data-action^='click:deferred-side-panel'].is-stuck {
top: 12px !important;
left: 12px !important;
background-color: var(--${ID}-bgColor);
z-index: 1;
}
:root:has(button[data-action^='click:deferred-side-panel'].is-stuck) [role=region][aria-labelledby] > h2:first-child {
margin-left: min(var(--pane-width, 0px), 40px);
}
.AppHeader .AppHeader-localBar.is-stuck {
left: auto !important;
right: 12px !important;
max-width: min-content;
padding-left: 0;
z-index: 9999;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav {
box-shadow: none;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-body {
display: none;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-actions {
visibility: visible !important;
padding-right: 0 !important;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-actions [popovertarget]:not([popover] *) {
zoom: 0.8;
}
.gh-header-sticky.is-stuck, [data-testid=issue-metadata-sticky] /*, [I WISH!] :stuck */ {
padding-right: 16px;
}
/* Place a sticky tabnav over the sticky header top left, invisible except on hover. */
:root, body {
--${ID}-bgColor: var(--overlay-bgColor, #ffffff);
--${ID}-borderColor: var(--overlay-borderColor, #d1d9e080);
--${ID}-highlight-bgColor: var(--highlight-neutral-bgColor, #fff8c5);
}
@media (prefers-color-scheme: dark) {
:root, body {
--${ID}-bgColor: var(--overlay-bgColor, #151b23);
--${ID}-borderColor: var(--overlay-borderColor, #3d444db3);
--${ID}-highlight-bgColor: var(--highlight-neutral-bgColor, #d2992266);
}
}
.tabnav.js-sticky {
width: unset !important;
}
.tabnav.is-stuck {
max-width: 80px;
max-height: 60px;
padding-top: 60px;
overflow: auto;
z-index: 999;
opacity: 0;
}
.tabnav.is-stuck:hover {
max-width: fit-content;
max-height: unset;
overflow: unset;
opacity: 1;
}
/* Hide children except the navigation tabs, and arrange those vertically. */
.tabnav.is-stuck > * {
display: none !important;
}
.tabnav.is-stuck > nav.tabnav-tabs {
display: flex !important;
flex-direction: column;
background-color: var(--${ID}-bgColor);
border: 1px solid var(--${ID}-borderColor);
}
/* Update tab styling for better vertical functionality. */
.tabnav.is-stuck *:where(.tabnav-tab.selected, .tabnav-tab[aria-selected=true], .tabnav-tab[aria-current]:not([aria-current=false])) {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.tabnav.is-stuck .tabnav-tab:hover {
background-color: var(--${ID}-highlight-bgColor);
}
/* Style the filter toggles. */
${CHECK_CONTAINER_SELECTOR} .${FILTER_CLASS} {
cursor: pointer;
display: inline;
position: relative;
z-index: 99;
border: none;
padding: 1px 3px;
background: transparent;
font-weight: inherit;
color: var(--fgColor-accent, var(--color-accent-fg, blue));
}
${CHECK_CONTAINER_SELECTOR} .${FILTER_CLASS}[aria-checked=true] {
font-weight: bold;
}
/* Hide any item whose status is marked as hidden. */
${ciCheckSelector({ ifHiddenStatus: "successful" })}:has( > [aria-label*=' successful ']) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "failing" })}:has( > [aria-label*=' failing ']) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "skipped" })}:has( > [aria-label*=' skipped ']) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "cancelled" })}:has( > [aria-label*=' cancelled ']) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "action required" })}:has(> [aria-label*=' action required ']) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "queued" })}:has( > [aria-label*=' queued ']) { display: none !important; }
/* As of 2024-12-30, "expected" checks appear under label "pending". */
${ciCheckSelector({ ifHiddenStatus: "expected" })}:has(> [aria-label*=' expected '], > [aria-label*=' pending ']) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "in progress" })}:has( > [aria-label*=' in progress ']) { display: none !important; }
/* classic <=2024-12 */
${ciCheckSelector({ ifHiddenStatus: "successful" })}:has( .merge-status-icon > .octicon-check.color-fg-success) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "failing" })}:has( .merge-status-icon > .octicon-x.color-fg-danger) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "skipped" })}:has( .merge-status-icon > .octicon-skip.neutral-check) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "cancelled" })}${CANCELLED_OR_ACTION_REQUIRED_SELECTOR}:not(.${ID}-action-required) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "action required" })}${CANCELLED_OR_ACTION_REQUIRED_SELECTOR}.${ID}-action-required { display: none !important; }
/* As of 2024-03-11, "queued" and "expected" differ only by "queued" having a link after the status icon [to /apps/github-actions for GH actions]. */
${ciCheckSelector({ ifHiddenStatus: "queued" })}:has( .merge-status-icon > [class*=pending]):has(.merge-status-icon + :not(div)) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "expected" })}:has(.merge-status-icon > [class*=pending]):has(.merge-status-icon + div) { display: none !important; }
/* As of 2024-03-08, "in progress" icons manifest as \`<div class="mx-auto">…</div>\` with no dedicated class. */
${ciCheckSelector({ ifHiddenStatus: "in progress" })}:has(.merge-status-icon > :not(.color-fg-success, .color-fg-danger, .neutral-check, [class*=pending])) { display: none !important; }
</style>`);
// Ensure stickiness on resize/scroll.
{
let tDebounce = -Infinity;
let waiting = false;
const ensureStickyNav = evt => {
const t = evt.timeStamp;
if (t < tDebounce) {
waiting ||= setTimeout(ensureStickyNav, 100, { timeStamp: t + 100 });
return;
}
tDebounce = t + 100;
waiting = false;
const selGlobalNav = ":root:not(:has(.AppHeader .AppHeader-localBar)) button[data-action^='click:deferred-side-panel']";
const selTabNav = ".js-pull-header-details ~ .tabnav";
const selRepoNav = ".AppHeader .AppHeader-localBar";
const selNeedsStickiness = ":not(.is-placeholder):not(.js-sticky)";
const stickyNavs = document.querySelectorAll(
[selGlobalNav, selTabNav, selRepoNav].map(s => `${s}${selNeedsStickiness}`).join(", "),
);
for (const el of stickyNavs) {
el.classList.add(..."js-sticky js-sticky-offset-scroll top-0".split(" "));
}
const navMenuItems = document.querySelectorAll(
`${selRepoNav}.is-stuck .UnderlineNav-actions [role=menu] > [hidden]`,
);
for (const el of navMenuItems) el.hidden = false;
// If anything was made sticky, dispatch a synthetic scroll in case it should already be stuck.
if (stickyNavs.length && !waiting) document.dispatchEvent(new Event("scroll"));
};
for (const capture of [true, false]) {
window.addEventListener("resize", ensureStickyNav, { capture, passive: true });
document.addEventListener("scroll", ensureStickyNav, { capture, passive: true });
}
const loadedKit = Promise.withResolvers();
loadedKit.promise.then(ensureStickyNav);
window.addEventListener("load", loadedKit.resolve, { once: true });
if (document.readyState === "complete") loadedKit.resolve({ timeStamp: 0 });
}
// Expand all comments/files/etc. on <{Alt,Option}+Shift+E>.
{
const LABEL = `[${ID}] expand all`;
const { scrollingElement = document.documentElement } = document;
const isInDoc = el =>
(el.compareDocumentPosition(document) & Node.DOCUMENT_POSITION_DISCONNECTED) === 0;
const isDifferent = (el, text) => !isInDoc(el) || el.textContent !== text;
const makeClassAttrSelector = (classAttrs, extra) =>
[...classAttrs].map(c => `[class='${CSS.escape(c)}']`).concat(extra || []).join(", ");
let restoreScroll;
const clickInViewport = async el => {
const top = el.getBoundingClientRect()?.top ?? null;
const viewportHeight =
window.visualViewport?.height ||
scrollingElement.clientHeight ||
window.innerHeight;
let forceRepeat = top == null;
if (top < 0 || top > viewportHeight) {
forceRepeat = true;
if (!restoreScroll) {
history.pushState({}, "");
restoreScroll = () => history.back();
}
scrollingElement.scrollTop += top - 50;
}
el.click();
await null; // to wait for effect propagation (e.g., DOM updates)
return forceRepeat;
}
const findAndClickToggles = async function* (T) {
const waiterClasses = new Set();
const waiters = new Map();
const shouldClick = el =>
/\b(?:more|load)\b/i.test(el.textContent) &&
getComputedStyle(el).visibility !== "hidden";
while (true) {
let forceRepeat = false;
const selMaybes = makeClassAttrSelector(waiterClasses, BUTTON_SELECTOR);
for (let el of [...document.querySelectorAll(selMaybes)].filter(shouldClick)) {
waiters.set(el, el.textContent);
yield clickInViewport(el);
}
for (let el of document.querySelectorAll(CLOSED_TOGGLE_SELECTOR)) {
const repeat = await clickInViewport(el);
yield repeat;
forceRepeat ||= repeat;
}
for (let [el, text] of waiters.entries()) {
if (isDifferent(el, text) && el.className) waiterClasses.add(el.className);
}
const selWaiters = makeClassAttrSelector(waiterClasses) || ":not(*)";
forceRepeat ||= !!document.querySelectorAll(selWaiters).length;
if (!forceRepeat) break;
await new Promise(resolve => setTimeout(resolve, T));
}
};
let active = false;
document.addEventListener("keydown", async ({key, altKey, ctrlKey, metaKey, shiftKey}) => {
// Normalize to overcome native composition such as "é".
const simpleKey = key.normalize("NFD").charAt(0).toUpperCase();
if (!(altKey && shiftKey && !ctrlKey && !metaKey && simpleKey === "E")) return;
if (active) return;
active = true;
restoreScroll = null;
try {
console.time(LABEL);
const workPoolConfig = { mode: "allSettled", capacity: 8 };
for await (const _ of makeWorkPool(findAndClickToggles(1000), workPoolConfig));
restoreScroll?.();
console.timeEnd(LABEL);
} finally {
active = false;
}
});
}
function findMatches (nodes, selector) {
return new Set([...nodes].flatMap(el => [
...(el.matches?.(selector) ? [el] : []),
...(el.querySelectorAll?.(selector) || []),
]));
}
function forEachMutation(target, options, callback) {
// As a convenience, invoke a childList callback with initial state.
if (options.childList) {
callback({ type: "childList", target, addedNodes: target.childNodes }, undefined, undefined);
}
const observer = new MutationObserver(mutations => {
mutations = [...mutations];
let i = 0;
for (const mutation of mutations) {
// Invoke the callback, ignoring the rest of mutations if it returns truthy.
if (callback(mutation, i++, mutations)) return;
}
});
observer.observe(target, options);
}
function makeWorkPool(source, config, processInput = x => x) {
// Validate arguments.
if (typeof config !== "object" || !config) config = { capacity: config, mode: undefined };
const { capacity = 10, mode = "all" } = config;
if (!(capacity === Infinity || (Number.isInteger(capacity) && capacity > 0))) {
throw RangeError("capacity must be a positive integer");
}
if (!(mode === "all" || mode === "allSettled")) throw RangeError("mode must be 'all' or 'allSettled'");
// Translate source into an async iterator of [index, input] pairs.
const inputs = (async function* (i = 0) {
for await (const input of source) yield [i++, input];
})();
// Concurrently consume up to `capacity` inputs, pushing the result of
// processing each into a linked chain of promises before consuming
// more.
let { promise, resolve, reject } = withResolvers();
let pending = 0;
const REJECTION = Symbol("rejection");
const take = async () => {
while (pending < capacity) {
pending++;
// Read an input, propagating failure into a fulfillment distinguishable from both
// success ({ done: false, value }) and successful completion
// ({ done: true, value: undefined }).
const inputP = inputs.next().catch(err => ({ done: true, value: [err] }));
const { done, value: inputData } = await inputP;
if (done) {
if (inputData) {
// Fail the chain upon inability to read an input.
reject(inputData[0]);
pending = NaN;
} else if (--pending <= 0) {
// Finish the chain only when pending drops to zero (if it's still positive,
// we'll get another chance in each future `take`).
// This dummy signaling record conveys no result.
resolve({ nextP: undefined, i: -1, result: undefined });
pending = NaN;
}
return;
}
// Asynchronously process the input, and when done, settle the then-current
// tail promise, rejecting it (and failing the chain) when processing fails in
// mode "all" but otherwise fulfilling it with a record that includes the source
// index to which it corresponds and a reference to a new [unsettled] successor
// (thereby extending the chain) and then trying to consume more input via `take()`.
const [i, input] = inputData;
const pushResult = (status, value, reason) => {
pending--;
const result =
// Analogous to `Promise.allSettled`, mode "allSettled" produces
// { status, value, reason } records.
mode === "allSettled" ? { status, value, reason } :
// Analogous to `Promise.all`, mode "all" produces unwrapped fulfillment
// values (or fails with the first rejection).
status === "fulfilled" ? value : REJECTION;
if (result === REJECTION) return void reject(reason);
const resolveCurrent = resolve;
({ promise, resolve, reject } = withResolvers());
resolveCurrent({ nextP: promise, i, result });
take();
};
(async x => processInput(x))(input).then(
result => pushResult("fulfilled", result),
err => pushResult("rejected", undefined, err),
);
}
};
take();
// Provide results as an async iterator of [index, result] pairs in result fulfillment
// order.
// Source order can be recovered with consumer code like
// const results = [];
// for (const [i, result] of workPool(...)) results[i] = result;
// or something more sophisticated to eagerly detect complete subsequences immediately above
// a previous high-water mark.
const results = (async function* (nextP) {
while (true) {
const { nextP: successor, i, result } = await nextP;
nextP = successor;
if (!successor) break;
yield [i, result];
}
})(promise);
for (const name of ["return", "throw"]) {
const builtin = results[name];
const forwarder = defineName(name, (...args) => {
(async () => inputs[name](...args))().catch(sink);
return Reflect.apply(builtin, results, args);
});
Object.defineProperty(results, name, { ...dataProperty, value: forwarder });
}
return results;
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment