Last active
July 12, 2026 08:46
-
-
Save erseco/85dbc72191e74450da4671277ad34f65 to your computer and use it in GitHub Desktop.
github-actions-bulk-delete.user.js
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 GitHub Actions Bulk Delete | |
| // @namespace https://github.com/erseco | |
| // @version 0.2.0 | |
| // @description Select and delete GitHub Actions workflow runs without confirmation. | |
| // @author Ernesto Serrano | |
| // @match https://github.com/*/*/actions* | |
| // @grant none | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| const CONFIG = { | |
| delayBetweenDeletesMs: 350, | |
| }; | |
| const SELECTORS = { | |
| row: '.Box-row[id^="check_suite_"]', | |
| runLink: 'a[href*="/actions/runs/"]', | |
| statusIcon: 'a[href*="/actions/runs/"] svg', | |
| optionsDetails: 'details.details-overlay', | |
| deleteForm: 'form[action*="/actions/runs/"] input[name="_method"][value="delete"]', | |
| }; | |
| const ICONS = { | |
| trash: ` | |
| <svg | |
| aria-hidden="true" | |
| viewBox="0 0 16 16" | |
| width="16" | |
| height="16" | |
| fill="currentColor" | |
| > | |
| <path d="M6.5 1.75a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 | |
| .75.75V2h3.75a.75.75 0 0 1 0 1.5h-.541l-.853 | |
| 10.237A1.75 1.75 0 0 1 10.112 15H5.888a1.75 1.75 | |
| 0 0 1-1.744-1.605L3.291 3.5H2.75a.75.75 0 0 1 | |
| 0-1.5H6.5v-.25ZM4.796 3.5l.812 9.77a.25.25 0 0 | |
| 0 .249.23h4.286a.25.25 0 0 0 .249-.23l.812-9.77H4.796Z"> | |
| </path> | |
| </svg> | |
| `, | |
| }; | |
| let deleting = false; | |
| let enhancementScheduled = false; | |
| /** | |
| * Pause execution for a specified duration. | |
| * | |
| * @param {number} milliseconds Duration in milliseconds. | |
| * | |
| * @return {Promise<void>} | |
| */ | |
| function sleep(milliseconds) { | |
| return new Promise((resolve) => { | |
| window.setTimeout(resolve, milliseconds); | |
| }); | |
| } | |
| /** | |
| * Extract the workflow run ID from a row. | |
| * | |
| * @param {HTMLElement} row Workflow run row. | |
| * | |
| * @return {string|null} | |
| */ | |
| function getRunId(row) { | |
| const link = row.querySelector(SELECTORS.runLink); | |
| if (!link) { | |
| return null; | |
| } | |
| const match = link.href.match(/\/actions\/runs\/(\d+)/); | |
| return match ? match[1] : null; | |
| } | |
| /** | |
| * Return the delete form contained in a workflow run row. | |
| * | |
| * @param {HTMLElement} row Workflow run row. | |
| * | |
| * @return {HTMLFormElement|null} | |
| */ | |
| function getDeleteForm(row) { | |
| const methodInput = row.querySelector(SELECTORS.deleteForm); | |
| if (!methodInput) { | |
| return null; | |
| } | |
| return methodInput.closest('form'); | |
| } | |
| /** | |
| * Delete one workflow run using GitHub's existing form. | |
| * | |
| * @param {HTMLElement} row Workflow run row. | |
| * | |
| * @return {Promise<void>} | |
| */ | |
| async function deleteRun(row) { | |
| const form = getDeleteForm(row); | |
| if (!form) { | |
| throw new Error('Delete form not found for this workflow run.'); | |
| } | |
| const formData = new FormData(form); | |
| const response = await fetch(form.action, { | |
| method: form.method || 'POST', | |
| body: formData, | |
| credentials: 'same-origin', | |
| redirect: 'follow', | |
| headers: { | |
| Accept: 'text/html, application/xhtml+xml', | |
| 'X-Requested-With': 'XMLHttpRequest', | |
| }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error( | |
| `GitHub returned HTTP ${response.status} while deleting the run.` | |
| ); | |
| } | |
| row.remove(); | |
| } | |
| /** | |
| * Return all selected workflow run rows. | |
| * | |
| * @return {HTMLElement[]} | |
| */ | |
| function getSelectedRows() { | |
| return [...document.querySelectorAll( | |
| `${SELECTORS.row} .ghabd-run-checkbox:checked` | |
| )] | |
| .map((checkbox) => checkbox.closest(SELECTORS.row)) | |
| .filter(Boolean); | |
| } | |
| /** | |
| * Update the bulk-delete toolbar. | |
| * | |
| * @return {void} | |
| */ | |
| function updateToolbar() { | |
| const selectedRows = getSelectedRows(); | |
| const deleteButton = document.querySelector( | |
| '#ghabd-delete-selected' | |
| ); | |
| const selectAll = document.querySelector('#ghabd-select-all'); | |
| if (deleteButton) { | |
| deleteButton.disabled = selectedRows.length === 0 || deleting; | |
| deleteButton.textContent = selectedRows.length > 0 | |
| ? `Delete selected (${selectedRows.length})` | |
| : 'Delete selected'; | |
| } | |
| if (selectAll) { | |
| const checkboxes = [ | |
| ...document.querySelectorAll('.ghabd-run-checkbox'), | |
| ]; | |
| const checked = checkboxes.filter( | |
| (checkbox) => checkbox.checked | |
| ).length; | |
| selectAll.checked = ( | |
| checkboxes.length > 0 && | |
| checked === checkboxes.length | |
| ); | |
| selectAll.indeterminate = ( | |
| checked > 0 && | |
| checked < checkboxes.length | |
| ); | |
| } | |
| } | |
| /** | |
| * Set toolbar status text. | |
| * | |
| * @param {string} text Status message. | |
| * | |
| * @return {void} | |
| */ | |
| function setStatus(text) { | |
| const status = document.querySelector('#ghabd-status'); | |
| if (status) { | |
| status.textContent = text; | |
| } | |
| } | |
| /** | |
| * Delete all selected workflow runs sequentially. | |
| * | |
| * @return {Promise<void>} | |
| */ | |
| async function deleteSelectedRuns() { | |
| if (deleting) { | |
| return; | |
| } | |
| const rows = getSelectedRows(); | |
| if (rows.length === 0) { | |
| return; | |
| } | |
| deleting = true; | |
| updateToolbar(); | |
| let deleted = 0; | |
| let failed = 0; | |
| for (const row of rows) { | |
| const runId = getRunId(row); | |
| setStatus( | |
| `Deleting ${deleted + failed + 1} of ${rows.length}…` | |
| ); | |
| try { | |
| await deleteRun(row); | |
| deleted += 1; | |
| } catch (error) { | |
| failed += 1; | |
| console.error( | |
| `[GitHub Actions Bulk Delete] Run ${runId}:`, | |
| error | |
| ); | |
| row.classList.add('ghabd-delete-failed'); | |
| } | |
| await sleep(CONFIG.delayBetweenDeletesMs); | |
| } | |
| deleting = false; | |
| updateToolbar(); | |
| if (failed > 0) { | |
| setStatus(`Deleted ${deleted}; failed ${failed}.`); | |
| return; | |
| } | |
| setStatus( | |
| `Deleted ${deleted} workflow run${deleted === 1 ? '' : 's'}.` | |
| ); | |
| } | |
| /** | |
| * Create the checkbox shown immediately before the status icon. | |
| * | |
| * @param {HTMLElement} row Workflow run row. | |
| * @param {string} runId Workflow run ID. | |
| * | |
| * @return {void} | |
| */ | |
| function addCheckbox(row, runId) { | |
| if (row.querySelector('.ghabd-run-checkbox')) { | |
| return; | |
| } | |
| const statusIcon = row.querySelector(SELECTORS.statusIcon); | |
| if (!statusIcon) { | |
| return; | |
| } | |
| const statusWrapper = statusIcon.parentElement; | |
| if (!statusWrapper) { | |
| return; | |
| } | |
| const checkboxWrapper = document.createElement('span'); | |
| checkboxWrapper.className = 'ghabd-checkbox-wrapper'; | |
| checkboxWrapper.innerHTML = ` | |
| <input | |
| type="checkbox" | |
| class="ghabd-run-checkbox" | |
| aria-label="Select workflow run ${runId}" | |
| title="Select workflow run ${runId}" | |
| > | |
| `; | |
| statusWrapper.parentElement.insertBefore( | |
| checkboxWrapper, | |
| statusWrapper | |
| ); | |
| checkboxWrapper | |
| .querySelector('.ghabd-run-checkbox') | |
| .addEventListener('click', (event) => { | |
| event.stopPropagation(); | |
| }); | |
| checkboxWrapper | |
| .querySelector('.ghabd-run-checkbox') | |
| .addEventListener('change', updateToolbar); | |
| } | |
| /** | |
| * Create the direct-delete icon immediately before the options menu. | |
| * | |
| * @param {HTMLElement} row Workflow run row. | |
| * @param {string} runId Workflow run ID. | |
| * | |
| * @return {void} | |
| */ | |
| function addDeleteButton(row, runId) { | |
| if (row.querySelector('.ghabd-delete-direct')) { | |
| return; | |
| } | |
| const optionsDetails = row.querySelector( | |
| SELECTORS.optionsDetails | |
| ); | |
| if (!optionsDetails || !optionsDetails.parentElement) { | |
| return; | |
| } | |
| const button = document.createElement('button'); | |
| button.type = 'button'; | |
| button.className = [ | |
| 'ghabd-delete-direct', | |
| 'btn-link', | |
| 'color-fg-danger', | |
| ].join(' '); | |
| button.title = `Delete workflow run ${runId}`; | |
| button.setAttribute( | |
| 'aria-label', | |
| `Delete workflow run ${runId}` | |
| ); | |
| button.innerHTML = ICONS.trash; | |
| optionsDetails.parentElement.insertBefore( | |
| button, | |
| optionsDetails | |
| ); | |
| button.addEventListener('click', async (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| if (button.disabled) { | |
| return; | |
| } | |
| button.disabled = true; | |
| button.classList.add('ghabd-is-deleting'); | |
| try { | |
| await deleteRun(row); | |
| updateToolbar(); | |
| } catch (error) { | |
| console.error( | |
| `[GitHub Actions Bulk Delete] Run ${runId}:`, | |
| error | |
| ); | |
| button.disabled = false; | |
| button.classList.remove('ghabd-is-deleting'); | |
| row.classList.add('ghabd-delete-failed'); | |
| window.alert( | |
| `Could not delete workflow run ${runId}. ` + | |
| 'Check the browser console for details.' | |
| ); | |
| } | |
| }); | |
| } | |
| /** | |
| * Enhance one GitHub Actions workflow run row. | |
| * | |
| * @param {HTMLElement} row Workflow run row. | |
| * | |
| * @return {void} | |
| */ | |
| function enhanceRow(row) { | |
| const runId = getRunId(row); | |
| if (!runId) { | |
| return; | |
| } | |
| row.dataset.ghabdRunId = runId; | |
| addCheckbox(row, runId); | |
| addDeleteButton(row, runId); | |
| } | |
| /** | |
| * Add the bulk-selection toolbar. | |
| * | |
| * @return {void} | |
| */ | |
| function addToolbar() { | |
| if (document.querySelector('#ghabd-toolbar')) { | |
| return; | |
| } | |
| const firstRow = document.querySelector(SELECTORS.row); | |
| if (!firstRow || !firstRow.parentElement) { | |
| return; | |
| } | |
| const toolbar = document.createElement('div'); | |
| toolbar.id = 'ghabd-toolbar'; | |
| toolbar.className = [ | |
| 'Box', | |
| 'd-flex', | |
| 'flex-items-center', | |
| 'flex-wrap', | |
| 'gap-2', | |
| 'p-2', | |
| 'mb-3', | |
| ].join(' '); | |
| toolbar.innerHTML = ` | |
| <label class="d-flex flex-items-center"> | |
| <input | |
| id="ghabd-select-all" | |
| type="checkbox" | |
| class="mr-2" | |
| > | |
| Select all visible | |
| </label> | |
| <button | |
| id="ghabd-delete-selected" | |
| type="button" | |
| class="btn btn-danger btn-sm" | |
| disabled | |
| > | |
| Delete selected | |
| </button> | |
| <span | |
| id="ghabd-status" | |
| class="color-fg-muted text-small" | |
| ></span> | |
| `; | |
| firstRow.parentElement.insertBefore(toolbar, firstRow); | |
| toolbar | |
| .querySelector('#ghabd-select-all') | |
| .addEventListener('change', (event) => { | |
| document | |
| .querySelectorAll('.ghabd-run-checkbox') | |
| .forEach((checkbox) => { | |
| checkbox.checked = event.currentTarget.checked; | |
| }); | |
| updateToolbar(); | |
| }); | |
| toolbar | |
| .querySelector('#ghabd-delete-selected') | |
| .addEventListener('click', deleteSelectedRuns); | |
| } | |
| /** | |
| * Add userscript styles. | |
| * | |
| * @return {void} | |
| */ | |
| function addStyles() { | |
| if (document.querySelector('#ghabd-styles')) { | |
| return; | |
| } | |
| const style = document.createElement('style'); | |
| style.id = 'ghabd-styles'; | |
| style.textContent = ` | |
| .ghabd-checkbox-wrapper { | |
| display: inline-flex; | |
| align-items: center; | |
| align-self: center; | |
| margin-right: 8px; | |
| flex: 0 0 auto; | |
| } | |
| .ghabd-run-checkbox { | |
| width: 16px; | |
| height: 16px; | |
| margin: 0; | |
| cursor: pointer; | |
| } | |
| .ghabd-delete-direct { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 32px; | |
| height: 32px; | |
| margin-right: 8px; | |
| padding: 0; | |
| border: 0; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| } | |
| .ghabd-delete-direct:hover { | |
| background: var(--bgColor-danger-muted, #ffebe9); | |
| } | |
| .ghabd-delete-direct:disabled { | |
| cursor: wait; | |
| opacity: 0.5; | |
| } | |
| .ghabd-delete-direct svg { | |
| pointer-events: none; | |
| } | |
| .ghabd-is-deleting svg { | |
| animation: ghabd-pulse 0.8s ease-in-out infinite alternate; | |
| } | |
| .ghabd-delete-failed { | |
| outline: 2px solid var(--fgColor-danger, #d1242f); | |
| outline-offset: -2px; | |
| } | |
| @keyframes ghabd-pulse { | |
| from { | |
| opacity: 0.25; | |
| } | |
| to { | |
| opacity: 1; | |
| } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| /** | |
| * Enhance the current GitHub Actions page. | |
| * | |
| * @return {void} | |
| */ | |
| function enhancePage() { | |
| addStyles(); | |
| document | |
| .querySelectorAll(SELECTORS.row) | |
| .forEach(enhanceRow); | |
| addToolbar(); | |
| updateToolbar(); | |
| } | |
| /** | |
| * Schedule page enhancement after dynamic GitHub updates. | |
| * | |
| * @return {void} | |
| */ | |
| function scheduleEnhancement() { | |
| if (enhancementScheduled) { | |
| return; | |
| } | |
| enhancementScheduled = true; | |
| window.requestAnimationFrame(() => { | |
| enhancementScheduled = false; | |
| enhancePage(); | |
| }); | |
| } | |
| const observer = new MutationObserver(scheduleEnhancement); | |
| observer.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| document.addEventListener('turbo:load', scheduleEnhancement); | |
| document.addEventListener('turbo:render', scheduleEnhancement); | |
| document.addEventListener('pjax:end', scheduleEnhancement); | |
| enhancePage(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment