Jenkins 联合构建 (v7.1 - 触发重试)

[健壮性] 增加自动重试机制。当触发 Job 遇到网络错误或服务器 5xx 错误时,会自动重试 3 次。

目前為 2025-10-30 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Jenkins 联合构建 (v7.1 - 触发重试)
// @namespace    http://tampermonkey.net/
// @version      7.1
// @description  [健壮性] 增加自动重试机制。当触发 Job 遇到网络错误或服务器 5xx 错误时,会自动重试 3 次。
// @author       Tandy (修改 by Gemini)
// @match        http://10.9.31.83:9001/job/sz-newcis-dev/*
// @grant        none
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // -----------------------------------------------------------------
    //  v7.1 更新日志 (by Gemini):
    //  1. [核心] 重构 triggerSingleBuild 函数。
    //  2. [核心] 当 Job 触发失败 (网络错误或 5xx 状态) 时,会自动重试 (最多3次,间隔2秒)。
    //  3. [UI]    步骤面板会实时显示重试状态 (例如 "触发失败 (第 1 次),2s 后重试...")。
    //  4. [UI]    只有在所有重试都失败后,构建链才会中止。
    // -----------------------------------------------------------------

    // =================================================================
    // ⚙️ [配置区] ⚙️
    // =================================================================

    // (新增) 重试配置
    const TRIGGER_MAX_RETRIES = 3; // 总共尝试 3 次
    const TRIGGER_RETRY_DELAY = 2000; // 每次间隔 2 秒 (2000ms)

    const JOB_DEFINITIONS = {
        'common': {
            name: 'Common',
            url: 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-common/build?delay=0sec'
        },
        'api': {
            name: 'API',
            url: 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-api/build?delay=0sec'
        },
        'web': {
            name: 'Web',
            url: 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-web/build?delay=0sec'
        },
        'bill': {
            name: 'Bill Service',
            url: 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-bill-service/build?delay=0sec'
        },
        'customer': {
            name: 'Customer Service',
            url: 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-customer-service/build?delay=0sec'
        },
        'system': {
            name: 'System Service',
            url: 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-system-service/build?delay=0sec'
        }
    };

    const PIPELINE_STEPS = [
        {
            type: 'parallel-wait',
            jobs: [
                { key: 'common', wait: true },
                { key: 'api', wait: true },
                { key: 'web', wait: false }
            ]
        },
        {
            type: 'sequential-trigger',
            jobs: [
                { key: 'bill' },
                { key: 'customer' },
                { key: 'system' }
            ]
        }
    ];

    // =================================================================
    // 🔚 [配置区结束]
    // =================================================================

    // --- 1. 定义全局 UI 元素和状态标志 ---
    let panelTitle, progressBar, progressContainer, stepContainer;
    let combinedButton, cancelButton;
    let isBuildCancelled = false;
    const PANEL_TITLE_DEFAULT = '🚀 联合构建 (v7.1)';

    class BuildChainError extends Error {
        constructor(message) {
            super(message);
            this.name = 'BuildChainError';
        }
    }

    // --- 2. 辅助函数 ---

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

    function addStyles() {
        const style = document.createElement('style');
        style.textContent = `
            @keyframes gm-progress-bar-stripes {
                from { background-position: 40px 0; }
                to { background-position: 0 0; }
            }
            #gm-build-panel { margin-top: 1em; }
            #gm-build-panel-title {
                display: block; font-size: 1.17em; font-weight: bold;
                color: #000; margin-bottom: 0.5em; padding-left: 5px;
            }
            #gm-build-panel .gm-button {
                width: 100%; box-sizing: border-box; padding: 8px 12px;
                font-size: 13px; border: none; border-radius: 4px;
                cursor: pointer; box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            }
            #gm-build-panel .gm-button:disabled { background-color: #aaa; cursor: not-allowed; }
            #gm-build-panel #gm-start-btn { background-color: #f0ad4e; color: white; }
            #gm-build-panel #gm-start-btn:hover:not(:disabled) { background-color: #ec971f; }
            #gm-build-panel #gm-cancel-btn { background-color: #d9534f; color: white; }
            #gm-build-panel #gm-cancel-btn:hover:not(:disabled) { background-color: #c9302c; }
            #gm-step-container {
                width: 100%; background: #fff; border: 1px solid #ccc;
                border-radius: 4px; margin-top: 8px; max-height: 200px;
                overflow-y: auto; font-size: 12px; box-sizing: border-box;
                display: none;
            }
            .gm-step-strong {
                min-width: 90px; display: inline-block; margin-right: 5px;
            }
            .gm-step-status { color: #555; }
        `;
        document.head.appendChild(style);
    }

    function getMyJenkinsCrumb() {
        const crumbInput = document.querySelector('input[name="Jenkins-Crumb"]');
        if (crumbInput) {
            return crumbInput.value;
        }
        console.error("未能找到 Jenkins Crumb input 元素。");
        return null;
    }

    // --- 3. UI 更新函数 ---

    function updateStatus(message, isError = false) {
        if (!panelTitle) return;
        console.log(message);
        panelTitle.innerText = message;
        panelTitle.style.color = isError ? 'red' : 'black';
    }

    function updateStepStatus(jobKey, message, icon, color = 'info') {
        const el = document.getElementById(`gm-step-${jobKey}`);
        if (!el) return;
        const iconEl = el.querySelector('.gm-step-icon');
        const statusEl = el.querySelector('.gm-step-status');
        if (icon) iconEl.innerText = icon;
        if (message) statusEl.innerText = message;
        switch (color) {
            case 'success': el.style.backgroundColor = '#dff0d8'; break;
            case 'warning': el.style.backgroundColor = '#fcf8e3'; break;
            case 'error': el.style.backgroundColor = '#f2dede'; break;
            case 'skipped': el.style.backgroundColor = '#f5f5f5'; break;
            case 'info': default: el.style.backgroundColor = '#fff'; break;
        }
    }

    function populateStepUI() {
        if (!stepContainer) return;
        stepContainer.innerHTML = '';
        stepContainer.style.display = 'block';
        for (const [key, jobData] of Object.entries(JOB_DEFINITIONS)) {
            const el = document.createElement('div');
            el.id = `gm-step-${key}`;
            el.style = 'padding: 5px 8px; border-bottom: 1px solid #eee;';
            el.innerHTML = `
                <span class="gm-step-icon">⚪</span>
                <strong class="gm-step-strong">${jobData.name}</strong>
                <span class="gm-step-status">未开始</span>
            `;
            stepContainer.appendChild(el);
        }
    }

    function skipPendingSteps() {
        for (const key of Object.keys(JOB_DEFINITIONS)) {
            const el = document.getElementById(`gm-step-${key}`);
            if (el && el.querySelector('.gm-step-status').innerText === '未开始') {
                updateStepStatus(key, '已跳过', '⏩', 'skipped');
            }
        }
    }

    function setProgressActive(show, text) {
        if (progressContainer) {
            progressContainer.style.display = show ? 'block' : 'none';
        }
        if (show && text) {
            updateStatus(text);
        }
    }

    function setBuildInProgressUI(inProgress) {
        if (!combinedButton || !cancelButton || !stepContainer) return;
        if (inProgress) {
            combinedButton.disabled = true;
            combinedButton.innerText = '▶ 正在构建...';
            combinedButton.style.display = 'none';
            cancelButton.style.display = 'block';
            populateStepUI();
        } else {
            combinedButton.disabled = false;
            combinedButton.innerText = '▶ 启动联合构建';
            combinedButton.style.display = 'block';
            cancelButton.style.display = 'none';
            setProgressActive(false);
        }
    }


    // --- 4. Jenkins API 核心函数 ---

    /**
     * (重构) 触发单个构建,带自动重试
     */
    async function triggerSingleBuild(jobKey, crumb) {
        const jobData = JOB_DEFINITIONS[jobKey];
        if (!jobData) throw new BuildChainError(`Job key "${jobKey}" 未在 JOB_DEFINITIONS 中定义。`);

        updateStepStatus(jobKey, '正在请求...', '⏳', 'warning');

        for (let attempt = 0; attempt < TRIGGER_MAX_RETRIES; attempt++) {
            if (isBuildCancelled) throw new BuildChainError('构建已取消');

            try {
                const response = await fetch(jobData.url, {
                    method: 'POST',
                    headers: { 'Jenkins-Crumb': crumb },
                    body: null
                });

                // 1. 成功 (201 Created)
                if (response.status === 201) {
                    const queueUrl = response.headers.get('Location');
                    if (!queueUrl) {
                        updateStepStatus(jobKey, '触发成功,但未找到 Queue URL!', '❌', 'error');
                        throw new BuildChainError(`[${jobData.name}] 未找到 Queue URL`);
                    }

                    let successMsg = '已进入队列';
                    if (attempt > 0) {
                        successMsg = `重试成功 (第 ${attempt + 1} 次),已入队`;
                    }
                    updateStepStatus(jobKey, successMsg, '⏳', 'warning');
                    return queueUrl; // 成功,退出函数
                }

                // 2. 客户端错误 (4xx),不应重试,立即失败
                if (response.status >= 400 && response.status < 500) {
                    updateStepStatus(jobKey, `请求失败 (状态: ${response.status}),请检查权限或 Job URL。`, '❌', 'error');
                    throw new BuildChainError(`[${jobData.name}] 构建请求失败 (状态: ${response.status})`);
                }

                // 3. 服务器错误 (5xx) 或其他临时问题,将重试
                throw new Error(`服务器状态: ${response.status}`);

            } catch (error) {
                // 捕获网络错误 (fetch failed) 或上面抛出的 5xx 错误
                console.warn(`[${jobData.name}] 触发失败 (第 ${attempt + 1} 次): ${error.message}`);

                // 检查是否是最后一次尝试
                if (attempt < TRIGGER_MAX_RETRIES - 1) {
                    // 还没用完重试次数
                    const retryMsg = `触发失败 (第 ${attempt + 1} 次),${TRIGGER_RETRY_DELAY / 1000}s 后重试...`;
                    updateStepStatus(jobKey, retryMsg, '⏳', 'warning');
                    await sleep(TRIGGER_RETRY_DELAY);
                } else {
                    // 已经是最后一次尝试,彻底失败
                    updateStepStatus(jobKey, `请求失败 (共 ${TRIGGER_MAX_RETRIES} 次): ${error.message}`, '❌', 'error');
                    throw new BuildChainError(`[${jobData.name}] 触发失败 (共 ${TRIGGER_MAX_RETRIES} 次)`);
                }
            }
        }
        // 按理说不会执行到这里,但作为兜底
        throw new BuildChainError(`[${jobData.name}] 未知的触发错误`);
    }

    /**
     * 从队列中轮询获取真实的 Build 编号 (v7.0 相同)
     */
    async function getBuildNumberFromQueue(jobKey, queueUrl, crumb) {
        const jobData = JOB_DEFINITIONS[jobKey];
        if (!queueUrl) throw new BuildChainError(`[${jobData.name}] 队列 URL 为空`);
        updateStepStatus(jobKey, '等待构建编号...', '⏳', 'warning');
        const pollInterval = 2000;
        let attempts = 0;
        const maxAttempts = 30;
        while (attempts < maxAttempts) {
            if (isBuildCancelled) throw new BuildChainError('构建已取消');
            try {
                const response = await fetch(`${queueUrl}api/json`, {
                    headers: { 'Jenkins-Crumb': crumb }
                });
                if (!response.ok) throw new Error(`Queue API 状态: ${response.status}`);
                const data = await response.json();
                if (data.cancelled) {
                    updateStepStatus(jobKey, '任务在队列中被取消', '❌', 'error');
                    throw new BuildChainError(`[${jobData.name}] 队列任务被取消`);
                }
                if (data.executable) {
                    const buildNumber = data.executable.number;
                    const buildUrl = data.executable.url;
                    updateStepStatus(jobKey, `已获取: #${buildNumber}`, '⏳', 'warning');
                    return { number: buildNumber, url: buildUrl };
                }
                await sleep(pollInterval);
                attempts++;
            } catch (error) {
                updateStepStatus(jobKey, `轮询队列失败`, '❌', 'error');
                throw error;
            }
        }
        updateStepStatus(jobKey, `等待构建编号超时`, '❌', 'error');
        throw new BuildChainError(`[${jobData.name}] 等待构建编号超时`);
    }

    /**
     * 轮询特定 Build 的状态 (v7.0 相同)
     */
    async function pollBuildStatus(jobKey, buildInfo, crumb) {
        const jobData = JOB_DEFINITIONS[jobKey];
        if (!buildInfo || !buildInfo.url) {
             updateStepStatus(jobKey, '缺少 Build 信息', '❌', 'error');
             throw new BuildChainError(`[${jobData.name}] 无法轮询,缺少 Build 信息`);
        }
        const buildUrl = buildInfo.url.endsWith('/') ? buildInfo.url : buildInfo.url + '/';
        const buildNumber = buildInfo.number;
        const pollInterval = 5000;
        let isBuilding = true;
        updateStepStatus(jobKey, `正在构建 #${buildNumber} (每 5s 检查)`, '⏳', 'warning');
        setProgressActive(true, `正在构建 ${jobData.name} #${buildNumber}...`);
        while (isBuilding) {
            if (isBuildCancelled) throw new BuildChainError('构建已取消');
            await sleep(pollInterval);
            try {
                const response = await fetch(`${buildUrl}api/json`, {
                    headers: { 'Jenkins-Crumb': crumb }
                });
                if (!response.ok) {
                    if (response.status === 404) continue;
                    throw new Error(`Build API 状态: ${response.status}`);
                }
                const data = await response.json();
                if (data.building === false) {
                    isBuilding = false;
                    const result = data.result;
                    if (result === 'SUCCESS') {
                        updateStepStatus(jobKey, `构建成功 (#${buildNumber})`, '✅', 'success');
                    } else {
                        updateStepStatus(jobKey, `构建 ${result} (#${buildNumber})`, '❌', 'error');
                        throw new BuildChainError(`[${jobData.name}] 构建失败,结果: ${result}`);
                    }
                    return result;
                }
                updateStepStatus(jobKey, `仍在构建 #${buildNumber}...`, '⏳', 'warning');
            } catch (error) {
                updateStepStatus(jobKey, `轮询状态失败 (#${buildNumber})`, '❌', 'error');
                throw error;
            }
        }
    }


    /**
     * 启动联合构建链 (v7.0 相同)
     */
    async function startCombinedChain() {
        isBuildCancelled = false;
        const crumb = getMyJenkinsCrumb();
        if (!crumb) {
            updateStatus("错误:无法获取 Crumb。", true);
            return;
        }

        setBuildInProgressUI(true);
        updateStatus('联合构建已启动...');

        const jobBuilds = {};

        try {
            // --- 循环执行流水线步骤 ---
            for (const step of PIPELINE_STEPS) {
                if (isBuildCancelled) throw new BuildChainError('构建已取消');

                // --- 1. 'parallel-wait' ---
                if (step.type === 'parallel-wait') {
                    updateStatus('步骤 1: 正在并行触发并等待...');
                    const triggerPromises = step.jobs.map(job =>
                        triggerSingleBuild(job.key, crumb)
                    );
                    const queueUrls = await Promise.all(triggerPromises);

                    const buildInfoPromises = [];
                    for (let i = 0; i < step.jobs.length; i++) {
                        const job = step.jobs[i];
                        if (job.wait) {
                            buildInfoPromises.push(
                                getBuildNumberFromQueue(job.key, queueUrls[i], crumb)
                                    .then(buildInfo => {
                                        jobBuilds[job.key] = buildInfo;
                                        return buildInfo;
                                    })
                            );
                        } else {
                            updateStepStatus(job.key, '已触发 (不等待)', '▶️', 'success');
                        }
                    }
                    await Promise.all(buildInfoPromises);

                    const pollPromises = [];
                    for (const job of step.jobs) {
                        if (job.wait) {
                            pollPromises.push(
                                pollBuildStatus(job.key, jobBuilds[job.key], crumb)
                            );
                        }
                    }
                    await Promise.all(pollPromises);
                    updateStatus('步骤 1: Common 和 API 均已构建成功!');
                }

                // --- 2. 'sequential-trigger' ---
                else if (step.type === 'sequential-trigger') {
                    updateStatus('步骤 2: 正在串行触发后续服务...');
                    for (const job of step.jobs) {
                        if (isBuildCancelled) throw new BuildChainError('构建已取消');
                        const queueUrl = await triggerSingleBuild(job.key, crumb); // (已包含重试)
                        if (queueUrl) {
                            // 触发后不等待,标记为成功
                            // (注意:triggerSingleBuild 已更新状态)
                        } else {
                            // (此分支理论上不会执行,因为 triggerSingleBuild 会 throw)
                            throw new BuildChainError(`[${JOB_DEFINITIONS[job.key].name}] 触发失败`);
                        }
                    }
                }
            }

            // --- 所有步骤成功 ---
            updateStatus('✅ 联合构建链全部完成!', false);
            setProgressActive(false);

        } catch (error) {
            // --- 捕获任何步骤中的失败 ---
            setProgressActive(false);
            if (error instanceof BuildChainError) {
                updateStatus(`❌ 构建链中止: ${error.message}`, true);
            } else {
                updateStatus(`❌ 发生意外错误: ${error.message}`, true);
                console.error(error);
            }
            skipPendingSteps();

        } finally {
            // --- 无论成功还是失败,最后都重置按钮 ---
            setBuildInProgressUI(false);
            if (panelTitle.style.color !== 'red') {
                 setTimeout(() => {
                    if (!isBuildCancelled && combinedButton.disabled === false) {
                         updateStatus(PANEL_TITLE_DEFAULT, false);
                    }
                 }, 5000);
            }
        }
    }


    // --- 5. UI 创建与初始化 ---

    /**
     * 创建主 UI 元素 (v7.0 相同)
     */
    function createUI() {

        const sidePanel = document.getElementById('side-panel');
        if (!sidePanel) {
            console.error('Jenkins 联合构建: 未能找到 #side-panel 元素,脚本停止。');
            return;
        }

        addStyles();

        // --- 3. 创建所有 UI 元素 ---
        const mainPanel = document.createElement('div');
        mainPanel.id = 'gm-build-panel';
        mainPanel.className = 'task';

        panelTitle = document.createElement('div');
        panelTitle.id = 'gm-build-panel-title';
        panelTitle.innerText = PANEL_TITLE_DEFAULT;

        const controlsContainer = document.createElement('div');
        controlsContainer.style = 'padding: 0 5px;';

        combinedButton = document.createElement('button');
        combinedButton.id = 'gm-start-btn';
        combinedButton.className = 'gm-button';
        combinedButton.innerText = '▶ 启动联合构建';
        combinedButton.onclick = startCombinedChain;

        cancelButton = document.createElement('button');
        cancelButton.id = 'gm-cancel-btn';
        cancelButton.className = 'gm-button';
        cancelButton.innerText = '■ 取消';
        cancelButton.style.display = 'none';
        cancelButton.onclick = function() {
            isBuildCancelled = true;
            updateStatus('正在取消,请稍候...', true);
        };

        progressContainer = document.createElement('div');
        progressContainer.id = 'gm-progress-container';
        progressContainer.style = `
            width: 100%; height: 10px; background-color: #e9ecef;
            border: 1px solid #ced4da; border-radius: 4px;
            box-sizing: border-box; display: none; overflow: hidden;
            margin: 8px 0;
        `;
        progressBar = document.createElement('div');
        progressBar.id = 'gm-progress-bar';
        progressBar.style = `
            height: 100%; width: 100%; background-color: #007bff;
            border-radius: 2px;
            background-size: 40px 40px;
            background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
            animation: gm-progress-bar-stripes 1s linear infinite;
        `;
        progressContainer.appendChild(progressBar);

        stepContainer = document.createElement('div');
        stepContainer.id = 'gm-step-container';

        // --- 4. 组装 DOM ---
        controlsContainer.appendChild(combinedButton);
        controlsContainer.appendChild(cancelButton);
        mainPanel.appendChild(panelTitle);
        mainPanel.appendChild(controlsContainer);
        mainPanel.appendChild(progressContainer);
        mainPanel.appendChild(stepContainer);

        // --- 5. 注入到页面 ---
        sidePanel.appendChild(mainPanel);

        const oldUpdateStatus = updateStatus;
        updateStatus = (message, isError = false) => {
            if (panelTitle) {
                oldUpdateStatus(message, isError);
            }
        };

        console.log('Jenkins 联合构建 (v7.1 - 触发重试) 已加载。');
    }

    // --- 6. 启动脚本 ---
    if (document.body) {
        createUI();
    } else {
        window.addEventListener('load', createUI);
    }

})();