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

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

// ==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);
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址