Last active
October 12, 2026 08:34
-
-
Save fffonion/171720e4b6a23f0587e50dbecbab1ebf to your computer and use it in GitHub Desktop.
Auto retrigger GHA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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