Skip to content

Instantly share code, notes, and snippets.

@fffonion
Last active October 12, 2026 08:34
Show Gist options
  • Select an option

  • Save fffonion/171720e4b6a23f0587e50dbecbab1ebf to your computer and use it in GitHub Desktop.

Select an option

Save fffonion/171720e4b6a23f0587e50dbecbab1ebf to your computer and use it in GitHub Desktop.
Auto retrigger GHA
// ==UserScript==
// @name Rerun GHA automagically
// @namespace http://yooooo.us
// @updateURL https://gist.github.com/fffonion/171720e4b6a23f0587e50dbecbab1ebf/raw/rerun-gha.user.js
// @downloadURL https://gist.github.com/fffonion/171720e4b6a23f0587e50dbecbab1ebf/raw/rerun-gha.user.js
// @version 0.50
// @description awesome!
// @author You
// @match https://github.com/*/*/pull/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
// @run-at document-idle
// @homepageURL https://gist.github.com/fffonion/171720e4b6a23f0587e50dbecbab1ebf
// @supportURL https://gist.github.com/fffonion/171720e4b6a23f0587e50dbecbab1ebf
// @grant none
// ==/UserScript==
(function () {
'use strict';
const TestsToRetry = /^(?!.*check-pr-title)(?!.*Changelog)(?!.*Pull Request Template Validation)(?!.*Backport PR manager approval).*$/;
const AutoRerunMaxTimes = 5;
const key = '__rerunGhaAutomagically';
window[key]?.dispose();
const logger = typeof console.context === 'function'
? console.context('storage', { color: 'lemonchiffon' }) : console;
let route = location.pathname;
let toolbar, failedButton, autoButton;
let monitoring = false;
let rerunCounter = 0;
let busy = false;
let nextRerunAt = 0;
let generation = 0;
let refreshTimer;
function checksSection() {
// Stack PRs add sections before/after Checks. Never use section[0].
if (!/^\/[^/]+\/[^/]+\/pull\/\d+\/?$/.test(location.pathname)) return null;
return document.querySelector('section[aria-label="Checks"]');
}
function grabChecks(section) {
const workflows = new Map();
if (!section) return { ready: false, pending: true, links: new Map() };
for (const row of section.querySelectorAll('li')) {
const link = row.querySelector('h4 a[href], [role="heading"] a[href]')
|| row.querySelector('a[href*="/actions/runs/"]');
if (!link) continue;
const name = link.textContent.trim();
if (!name || !TestsToRetry.test(name)) continue;
const url = new URL(link.getAttribute('href'), location.origin);
const match = url.pathname.match(/^\/[^/]+\/[^/]+\/actions\/runs\/\d+(?=\/|$)/);
if (url.origin !== location.origin || !match) continue;
// Prefer the row's accessible status; avoid brittle childNodes indices.
const label = row.getAttribute('aria-label') || row.innerText;
const result = label.startsWith(name) ? label.slice(name.length) : row.innerText.replace(name, '');
const state = workflows.get(match[0]) || { name: name.split('/')[0].trim(), failed: false, pending: false };
state.failed ||= /(?:Failing after|Failed|Cancelled|Canceled|Timed out)/i.test(result);
state.pending ||= /(?:Queued|This check has started|In progress|Pending|Waiting|Requested)/i.test(result);
workflows.set(match[0], state);
}
const links = new Map();
let pending = false;
for (const [url, state] of workflows) {
pending ||= state.pending;
// Don't rerun a workflow while another job in that run is active.
if (state.failed && !state.pending) links.set(url, state.name);
}
return { ready: workflows.size > 0, pending, links };
}
function setText(element, text) {
if (element.textContent !== text) element.textContent = text;
}
function stopMonitor() {
monitoring = false;
if (autoButton) setText(autoButton, 'Auto Rerun');
}
function mergeActionRow(section) {
// The merge footer is a sibling of Checks, including on stacked PRs.
const mergeBox = section.parentElement;
const methodButton = mergeBox.querySelector('button[aria-label="Select merge method"]');
const mergeButton = methodButton || [...mergeBox.querySelectorAll('button')].find(button =>
/^(?:Rebase and merge|Squash and merge|Merge pull request)$/.test(button.textContent.trim()));
return mergeButton?.closest('.d-flex.flex-sm-row') || null;
}
function mount(section) {
const actionRow = mergeActionRow(section);
if (!actionRow) {
toolbar?.remove();
return false;
}
if (!toolbar) {
toolbar = document.createElement('div');
toolbar.id = 'gha-rerun-controls';
toolbar.setAttribute('role', 'group');
toolbar.setAttribute('aria-label', 'GitHub Actions rerun controls');
toolbar.style.cssText = 'display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;margin-left:auto;box-sizing:border-box;flex-shrink:0;max-width:100%;';
failedButton = document.createElement('button');
autoButton = document.createElement('button');
for (const button of [failedButton, autoButton]) {
button.type = 'button';
// Avoid GitHub's comment/submit behavior classes.
button.className = 'btn btn-sm';
}
failedButton.textContent = 'Rerun Failed';
autoButton.textContent = 'Auto Rerun';
toolbar.append(failedButton, autoButton);
failedButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
void rerun();
});
autoButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (monitoring) {
stopMonitor();
} else {
monitoring = true;
rerunCounter = 0;
logger.log('Auto rerun monitor started, max rerun counter ' + AutoRerunMaxTimes);
void tick();
}
refresh();
});
}
// Participate in the footer's layout instead of positioning against the whole merge box.
if (toolbar.parentElement !== actionRow) {
actionRow.append(toolbar);
}
return true;
}
function refresh() {
if (route !== location.pathname) {
route = location.pathname;
generation++;
stopMonitor();
busy = false;
nextRerunAt = 0;
toolbar?.remove();
}
const section = checksSection();
if (!section) {
toolbar?.remove();
return grabChecks(null);
}
if (!mount(section)) return grabChecks(section);
const status = grabChecks(section);
failedButton.disabled = busy || Date.now() < nextRerunAt || status.links.size === 0;
autoButton.disabled = busy;
setText(autoButton, monitoring ? 'Stop Auto Rerun (' + rerunCounter + '/' + AutoRerunMaxTimes + ')' : 'Auto Rerun');
return status;
}
async function request(url, options = {}) {
const response = await fetch(url, { credentials: 'same-origin', ...options });
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response;
}
async function rerun() {
const status = refresh();
if (busy || Date.now() < nextRerunAt || !status?.links.size) return false;
busy = true;
const currentGeneration = generation;
const currentRoute = route;
refresh();
try {
for (const [url, name] of status.links) {
if (currentGeneration !== generation || currentRoute !== location.pathname) return false;
const response = await request(url + '/job_rerun_dialogs_partial');
const html = new DOMParser().parseFromString(await response.text(), 'text/html');
const token = html.querySelector('input[name="authenticity_token"]')?.value;
if (!token) throw new Error('Missing authenticity_token for ' + name);
if (currentGeneration !== generation || currentRoute !== location.pathname) return false;
await request(url + '/rerequest_check_suite', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ _method: 'put', authenticity_token: token, only_failed_check_runs: 'true' }),
});
logger.log('Rerunning workflow ' + name + ' at ' + location.origin + url);
}
return true;
} catch (error) {
logger.error('Could not rerun workflow:', error);
stopMonitor();
return false;
} finally {
if (currentGeneration === generation && currentRoute === location.pathname) {
busy = false;
nextRerunAt = Date.now() + 10000;
refresh();
}
}
}
async function tick() {
const status = refresh();
if (!monitoring || busy || !status?.ready || Date.now() < nextRerunAt) return;
if (status.links.size > 0) {
if (rerunCounter >= AutoRerunMaxTimes) {
logger.log('Already rerun ' + AutoRerunMaxTimes + ' times, still failing 😭');
stopMonitor();
} else if (await rerun()) {
rerunCounter++;
}
} else if (!status.pending) {
stopMonitor();
}
refresh();
}
// GitHub renders asynchronously and replaces the merge box on navigation.
const observer = new MutationObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refresh, 150);
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-label'] });
const poll = setInterval(() => { void tick(); }, 5000);
window[key] = { dispose() {
observer.disconnect();
clearTimeout(refreshTimer);
clearInterval(poll);
generation++;
stopMonitor();
toolbar?.remove();
} };
refresh();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment