GitHub Retry Failed GitHub Jobs for PR

Adds a button to retry all failed jobs on a GitHub PR page

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GitHub Retry Failed GitHub Jobs for PR
// @version      2.9
// @description  Adds a button to retry all failed jobs on a GitHub PR page
// @author       Sergio Dias
// @match        https://github.com/*
// @grant        none
// @license      MIT
// @namespace http://tampermonkey.net/
// ==/UserScript==

(function () {
    'use strict';

    const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
    <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/>
  </svg>`;

    const DEFAULT_TEXT = 'Retry Failed Jobs';
    const BUTTON_ID = 'retry-all-failed-btn';

    const COLORS = {
        default: '#6e3630',
        hover: '#5a2d28',
        loading: '#6e7681',
        success: '#238636',
    };

    function sleep(ms) {
        return new Promise((resolve) => setTimeout(resolve, ms));
    }

    function getRepoInfo() {
        const match = window.location.pathname.match(/\/([^/]+)\/([^/]+)\/pull\/\d+/);
        return match ? { owner: match[1], repo: match[2] } : null;
    }

    function isPRPage() {
        return /\/[^/]+\/[^/]+\/pull\/\d+/.test(window.location.pathname);
    }

    function extractRunIds() {
        const failedItems = document.querySelectorAll('li[aria-label*="failing"]');
        const runIds = new Set();

        failedItems.forEach((item) => {
            const link = item.querySelector('a[href*="/actions/runs/"]');
            if (link) {
                const match = link.href.match(/\/actions\/runs\/(\d+)/);
                if (match) {
                    runIds.add(match[1]);
                }
            }
        });

        return Array.from(runIds);
    }

    function findButtonByText(container, pattern, visibleOnly = false) {
        return Array.from(container.querySelectorAll('button')).find(
            (btn) => pattern.test(btn.textContent) && (!visibleOnly || btn.offsetParent !== null)
        );
    }

    function setButtonState(button, text, color, disabled = false) {
        button.innerHTML = `${RETRY_ICON} ${text}`;
        button.style.backgroundColor = color;
        button.disabled = disabled;
    }

    function resetButton(button) {
        setButtonState(button, DEFAULT_TEXT, COLORS.default, false);
    }

    function removeRetryButton() {
        const btn = document.querySelector(`#${BUTTON_ID}`);
        if (btn) btn.parentElement.remove();
    }

    function addRetryButton() {
        if (!isPRPage()) return;
        if (document.querySelector(`#${BUTTON_ID}`)) return;

        const buttonContainer = document.createElement('div');
        buttonContainer.style.cssText = `
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 9999;
        `;

        const retryButton = document.createElement('button');
        retryButton.id = BUTTON_ID;
        retryButton.type = 'button';
        retryButton.style.cssText = `
            background-color: ${COLORS.default};
            color: white;
            border: none;
            padding: 10px 18px;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            display: flex;
            align-items: center;
            gap: 6px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.25);
        `;
        retryButton.innerHTML = `${RETRY_ICON} ${DEFAULT_TEXT}`;
        retryButton.title = 'Click to retry all failed workflow jobs';

        retryButton.addEventListener('click', (e) => {
            e.stopPropagation();
            e.preventDefault();
            retryAllFailedJobs();
        });

        retryButton.addEventListener('mouseenter', () => {
            if (!retryButton.disabled) {
                retryButton.style.backgroundColor = COLORS.hover;
            }
        });
        retryButton.addEventListener('mouseleave', () => {
            if (!retryButton.disabled) {
                retryButton.style.backgroundColor = COLORS.default;
            }
        });

        buttonContainer.appendChild(retryButton);
        document.body.appendChild(buttonContainer);
    }

    async function retryAllFailedJobs() {
        const button = document.querySelector(`#${BUTTON_ID}`);
        if (!button) return;

        setButtonState(button, 'Finding failed jobs...', COLORS.loading, true);

        try {
            const repoInfo = getRepoInfo();
            if (!repoInfo) {
                alert('Could not parse repository information from URL');
                return;
            }

            const runIds = extractRunIds();
            if (runIds.length === 0) {
                alert('No failed jobs found to retry.');
                return;
            }

            let succeeded = 0;
            let failed = 0;
            const errors = [];

            for (const runId of runIds) {
                setButtonState(button, `Retrying workflow ${succeeded + failed + 1}/${runIds.length}...`, COLORS.loading, true);

                try {
                    const result = await rerunViaPopup(repoInfo, runId);
                    if (result.success) {
                        succeeded++;
                        console.log(`Successfully triggered rerun for ${runId}`);
                    } else {
                        failed++;
                        errors.push(`Run ${runId}: ${result.error}`);
                    }
                } catch (e) {
                    failed++;
                    errors.push(`Run ${runId}: ${e.message}`);
                }

                await sleep(1000);
            }

            if (failed > 0) {
                console.error('Failed to retry some workflows:', errors);
                alert(
                    `Retried ${succeeded} workflow(s). ${failed} failed.\n\nYou may need to retry manually from the Actions tab.\n\nErrors:\n${errors.join('\n')}`
                );
                resetButton(button);
            } else {
                setButtonState(button, `Retried ${succeeded} workflow(s)!`, COLORS.success, true);
                setTimeout(() => resetButton(button), 3000);
            }
        } catch (error) {
            console.error('Error retrying failed jobs:', error);
            alert(`Error: ${error.message}`);
            resetButton(button);
        }
    }

    async function rerunViaPopup(repoInfo, runId) {
        return new Promise((resolve) => {
            const actionsUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}`;
            const popup = window.open(actionsUrl, `rerun_${runId}`, 'width=1000,height=700,left=100,top=100');

            if (!popup) {
                resolve({ success: false, error: 'Popup blocked. Please allow popups for github.com' });
                return;
            }

            let attempts = 0;
            const maxAttempts = 40;

            const checkAndClick = setInterval(async () => {
                attempts++;

                try {
                    if (popup.closed) {
                        clearInterval(checkAndClick);
                        resolve({ success: true, error: null });
                        return;
                    }

                    const popupDoc = popup.document;

                    const menuButton = findButtonByText(popupDoc, /Re-run jobs/i, true);

                    if (menuButton) {
                        clearInterval(checkAndClick);
                        menuButton.click();

                        await sleep(300);

                        const retryButton = findButtonByText(popupDoc, /Re-run failed jobs/i);

                        if (!retryButton) {
                            popup.close();
                            resolve({ success: false, error: 'Could not find "Re-run failed jobs" button' });
                            return;
                        }

                        retryButton.click();
                        await sleep(300);

                        const dialog = popupDoc.querySelector('#rerun-dialog-failed');
                        if (dialog) {
                            const confirmButton = findButtonByText(dialog, /Re-run jobs/i);
                            if (confirmButton) {
                                confirmButton.click();
                            }
                        }

                        await sleep(500);
                        popup.close();
                        resolve({ success: true, error: null });
                        return;
                    }

                    if (attempts >= maxAttempts) {
                        clearInterval(checkAndClick);
                        popup.close();
                        resolve({ success: false, error: 'Timeout waiting for re-run button' });
                    }
                } catch (e) {
                    if (attempts >= maxAttempts) {
                        clearInterval(checkAndClick);
                        popup.close();
                        resolve({ success: false, error: `Could not access popup: ${e.message}` });
                    }
                }
            }, 500);
        });
    }

    function handleNavigation() {
        if (isPRPage()) {
            addRetryButton();
        } else {
            removeRetryButton();
        }
    }

    // Monkey-patch history methods to detect SPA navigation
    ['pushState', 'replaceState'].forEach((method) => {
        const original = history[method];
        history[method] = function (...args) {
            original.apply(this, args);
            handleNavigation();
        };
    });

    // Back/forward button
    window.addEventListener('popstate', handleNavigation);

    // Safety net for GitHub Turbo and other edge cases
    let debounceTimer;
    let lastUrl = location.href;
    const observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(handleNavigation, 300);
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Initialize
    setTimeout(handleNavigation, 1000);
})();