Skip to content

Instantly share code, notes, and snippets.

@gibson042
Last active April 18, 2024 20:25
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 Include pull request tabs in the sticky header and support CI check filtering by status.
// @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.1.3
// @date 2024-04-18
// @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.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 FILTER_CLASS = `${ID}-status-filter`;
const STATUSES = ["successful", "failing" /*, "cancelled"*/, "skipped", "action required", "queued", "expected", "in progress"];
const STATUS_SEP = "::";
const STABLE_CONTAINER_SELECTOR = ":has(> .pull-merging)"; // "stable" as in "not dynamically replaced"
const CHECK_CONTAINER_SELECTOR = ".mergeability-details";
const CHECK_SUMMARY_SELECTOR = `${CHECK_CONTAINER_SELECTOR} .status-meta`;
const HIDDEN_STATUSES_ATTR = `data-${ID}-hidden-statuses`;
const HIDDEN_STATUSES_PROP = HIDDEN_STATUSES_ATTR.replace(/^data-|-([a-z])/g, (_, letter) => letter?.toUpperCase() || "");
const log = console.log.bind(console, `[${ID}]`);
const warn = console.warn.bind(console, `[${ID}]`);
const error = console.error.bind(console, `[${ID}]`);
// 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();
const 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);
};
forEachMutation(document.body, { subtree: true, childList: true, characterData: true }, mutation => {
const nodes = mutation.type === "childList" ? mutation.addedNodes : [mutation.target.parentNode];
const summaries = new Set([...nodes].flatMap(el => [
...(el.matches?.(CHECK_SUMMARY_SELECTOR) ? [el] : []),
...(el.querySelectorAll?.(CHECK_SUMMARY_SELECTOR) || []),
]));
for (const elSummary of summaries) {
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("change", evt => {
const { target } = evt;
if (!target?.matches?.(`${CHECK_SUMMARY_SELECTOR} input.${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.
// And to prevent GitHub from considering the content dirty (which causes it stop updating),
// keep the checked attributes in sync with live state.
if (target.checked) {
hidden.delete(target.dataset.status);
target.setAttribute("checked", "");
} else {
hidden.add(target.dataset.status);
target.removeAttribute("checked")
}
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));
elSummary.innerHTML = elSummary.innerHTML.replace(
RegExp(`[0-9]+\\s+(${STATUSES.join("|")})`, "g"),
(text, status) => {
const checkedAttr = hidden.has(status) ? "" : "checked";
const checkboxHtml = `<input type=checkbox class="${FILTER_CLASS}" data-status="${status}" ${checkedAttr}>`;
return `<label class="${FILTER_CLASS}" title="toggle visibility">${checkboxHtml}<a>${text}</a></label>`;
},
);
}
});
const ciCheckSelector = ({ ifHiddenStatus }) => {
const toggledStableContainer = `${STABLE_CONTAINER_SELECTOR}[${HIDDEN_STATUSES_ATTR}*='::${ifHiddenStatus}::']`;
return `${toggledStableContainer} ${CHECK_CONTAINER_SELECTOR} .merge-status-list .merge-status-item`;
};
document.head.insertAdjacentHTML('beforeend', `<style type="text/css">
/* Place a sticky tabnav over the sticky header top left, invisible except on hover. */
.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(--color-canvas-default, white);
border: 1px solid var(--color-border-default, #d0d7de);
}
/* 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(--color-btn-hover-bg, #f3f4f6);
}
/* Style the filter toggles. */
${CHECK_CONTAINER_SELECTOR} label.${FILTER_CLASS} {
cursor: pointer;
font-weight: inherit;
}
${CHECK_CONTAINER_SELECTOR} label.${FILTER_CLASS}:has(input.${FILTER_CLASS}:checked) {
font-weight: bold;
}
${CHECK_CONTAINER_SELECTOR} label.${FILTER_CLASS} input.${FILTER_CLASS} {
display: none;
}
/* Hide any item whose status is marked as hidden. */
${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; }
/* As of 2024-04-18, "failing" and "cancelled" cannot be distinguished. */
${ciCheckSelector({ ifHiddenStatus: "cancelled" })}:has( .merge-status-icon > .octicon-x.color-fg-danger) {}
${ciCheckSelector({ ifHiddenStatus: "skipped" })}:has( .merge-status-icon > .octicon-skip.neutral-check) { display: none !important; }
${ciCheckSelector({ ifHiddenStatus: "action required" })}:has(.merge-status-icon > .octicon-stop.neutral-check) { 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>`);
// Update every tabnav (without MutationObserver to avoid races).
setInterval(() => {
document.querySelectorAll(".js-pull-header-details ~ .tabnav:not(.is-placeholder):not(.js-sticky)").forEach(el => el.classList.add(..."js-sticky js-sticky-offset-scroll top-0".split(" ")));
}, 1000);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment