Jenkins 联合构建 (v5.0 - 智能轮询)

智能轮询 common 和 api 的构建状态,成功后再执行后续步骤

当前为 2025-10-30 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Jenkins 联合构建 (v5.0 - 智能轮询)
// @namespace    http://tampermonkey.net/
// @version      5.0
// @description  智能轮询 common 和 api 的构建状态,成功后再执行后续步骤
// @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';

    // -----------------------------------------------------------------
    //  v5.0 更新日志 (by Gemini):
    //  1. [核心] 移除了 startVisibleTimer(60000) 固定时间等待。
    //  2. [核心] 增加了 getBuildNumberFromQueue 和 pollBuildStatus 新函数。
    //  3. [核心] startCombinedChain 现在会智能轮询 Common 和 API 的真实构建状态。
    //  4. [核心] triggerSingleBuild 现在会返回 Jenkins 队列 URL (Queue URL)。
    //  5. [UI]    进度条被保留,用于在轮询期间显示“处理中”的动画,而不是倒计时。
    // -----------------------------------------------------------------

    // 1. 定义所有函数

    let statusDisplay, progressBar, progressContainer;

    /**
     * 辅助函数:异步等待
     * @param {number} ms - 等待的毫秒数
     */
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    /**
     * 更新底部悬浮窗的状态文本
     * @param {string} message - 显示的消息
     * @param {boolean} [isError=false] - 是否为错误消息 (红色)
     */
    function updateStatus(message, isError = false) {
        if (!statusDisplay) return;
        console.log(message);
        statusDisplay.innerText = message;
        statusDisplay.style.color = isError ? 'red' : 'black';
        statusDisplay.style.borderColor = isError ? 'red' : '#ccc';
    }

    /**
     * (新增) 控制进度条的显示
     * @param {boolean} show - true: 显示, false: 隐藏
     * @param {string} [text] - (可选) 显示时顺便更新状态
     */
    function setProgressActive(show, text) {
        if (progressContainer) {
            progressContainer.style.display = show ? 'block' : 'none';
        }
        if (show && text) {
            updateStatus(text);
        } else if (!show) {
            // 隐藏时,如果进度条是满的 (100%),重置它
            if (progressBar.style.width === '100%') {
                 progressBar.style.width = '0%';
            }
        }
    }

    /**
     * (新增) 注入CSS动画,用于实现不确定的进度条
     */
    function addStyles() {
        const style = document.createElement('style');
        style.textContent = `
            @keyframes gm-progress-bar-stripes {
                from { background-position: 40px 0; }
                to { background-position: 0 0; }
            }
        `;
        document.head.appendChild(style);
    }


    /**
     * 获取 Jenkins Crumb (用于 POST 请求认证)
     */
    function getMyJenkinsCrumb() {
        const crumbInput = document.querySelector('input[name="Jenkins-Crumb"]');
        if (crumbInput) {
            return crumbInput.value;
        }
        console.error("未能找到 Jenkins Crumb input 元素。");
        return null;
    }

    /**
     * (修改) 触发单个构建
     * @param {string} jobName - 任务名称 (用于日志)
     * @param {string} buildUrl - 任务的 build URL
     * @param {string} crumb - Jenkins Crumb
     * @returns {Promise<string|null>} - 成功时返回 队列URL (Queue URL),失败时返回 null
     */
    async function triggerSingleBuild(jobName, buildUrl, crumb) {
        updateStatus(`[${jobName}] 正在请求构建...`);
        try {
            const response = await fetch(buildUrl, {
                method: 'POST',
                headers: { 'Jenkins-Crumb': crumb },
                body: null
            });

            // 201 Created 是 Jenkins 接受构建请求的正确状态码
            if (response.status === 201) {
                const queueUrl = response.headers.get('Location');
                if (!queueUrl) {
                    updateStatus(`[${jobName}] 构建已触发,但未找到 Queue URL!`, true);
                    return null;
                }
                updateStatus(`[${jobName}] 构建已进入队列。`);
                return queueUrl;
            } else {
                updateStatus(`[${jobName}] 构建请求失败!状态: ${response.status}`, true);
                return null;
            }
        } catch (error) {
            updateStatus(`[${jobName}] 发送请求时发生网络错误: ${error}`, true);
            return null;
        }
    }

    /**
     * (新增) 从队列中轮询获取真实的 Build 编号和 URL
     * @param {string} jobName - 任务名称
     * @param {string} queueUrl - triggerSingleBuild 返回的队列 URL
     * @param {string} crumb - Jenkins Crumb
     * @returns {Promise<object|null>} - 成功时返回 { number, url },失败时 null
     */
    async function getBuildNumberFromQueue(jobName, queueUrl, crumb) {
        if (!queueUrl) return null;

        updateStatus(`[${jobName}] 正在等待分配构建编号...`);
        const pollInterval = 2000; // 2 秒轮询一次
        let attempts = 0;
        const maxAttempts = 30; // 最多等待 60 秒

        while (attempts < maxAttempts) {
            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) {
                    updateStatus(`[${jobName}] 队列中的任务被取消。`, true);
                    return null;
                }

                if (data.executable) {
                    const buildNumber = data.executable.number;
                    const buildUrl = data.executable.url;
                    updateStatus(`[${jobName}] 已获取构建编号: #${buildNumber}`);
                    return { number: buildNumber, url: buildUrl };
                }

                // 任务仍在队列中,等待
                await sleep(pollInterval);
                attempts++;

            } catch (error) {
                updateStatus(`[${jobName}] 轮询队列失败: ${error}`, true);
                return null;
            }
        }

        updateStatus(`[${jobName}] 等待构建编号超时。`, true);
        return null;
    }


    /**
     * (新增) 轮询特定 Build 的状态,直到它完成
     * @param {string} jobName - 任务名称
     * @param {object} buildInfo - 包含 { number, url } 的对象
     * @param {string} crumb - Jenkins Crumb
     * @returns {Promise<string>} - "SUCCESS", "FAILURE", 或 "ABORTED"
     */
    async function pollBuildStatus(jobName, buildInfo, crumb) {
        if (!buildInfo || !buildInfo.url) {
             updateStatus(`[${jobName}] 无法轮询:缺少 Build 信息。`, true);
             return "FAILURE";
        }

        const buildUrl = buildInfo.url.endsWith('/') ? buildInfo.url : buildInfo.url + '/';
        const buildNumber = buildInfo.number;
        const pollInterval = 5000; // 5 秒轮询一次
        let isBuilding = true;

        updateStatus(`[${jobName} #${buildNumber}] 正在构建中... (每 5s 检查)`);

        while (isBuilding) {
            await sleep(pollInterval);
            try {
                const response = await fetch(`${buildUrl}api/json`, {
                    headers: { 'Jenkins-Crumb': crumb }
                });
                if (!response.ok) {
                     // 如果是 404,可能是 Build 仍在初始化,再试一次
                    if (response.status === 404) {
                        updateStatus(`[${jobName} #${buildNumber}] API 尚未就绪 (404),重试中...`);
                        continue;
                    }
                    throw new Error(`Build API 状态: ${response.status}`);
                }
                const data = await response.json();

                if (data.building === false) {
                    isBuilding = false;
                    const result = data.result; // "SUCCESS", "FAILURE", "ABORTED"
                    updateStatus(`[${jobName} #${buildNumber}] 构建完成!结果: ${result}`, result !== "SUCCESS");
                    return result;
                }
                // 仍在构建中,继续循环
                updateStatus(`[${jobName} #${buildNumber}] 仍在构建中...`);

            } catch (error) {
                updateStatus(`[${jobName} #${buildNumber}] 轮询构建状态失败: ${error}`, true);
                return "FAILURE";
            }
        }
    }


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

        // --- 步骤 1: 同时触发 Common, API 和 Web ---
        updateStatus('步骤 1: 正在同时触发 Common, API 和 Web...');
        const commonUrl = 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-common/build?delay=0sec';
        const apiUrl = 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-api/build?delay=0sec';
        const webUrl = 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-web/build?delay=0sec';

        // 注意:我们只关心 common 和 api 的队列 URL,web 触发后不管
        const [commonQueueUrl, apiQueueUrl] = await Promise.all([
            triggerSingleBuild('Common', commonUrl, crumb),
            triggerSingleBuild('API', apiUrl, crumb),
            triggerSingleBuild('Web', webUrl, crumb) // Web 也触发,但不捕获其 URL
        ]);

        if (!commonQueueUrl || !apiQueueUrl) {
            updateStatus("步骤 1 失败:Common 或 API 未能成功进入队列。构建链中止。", true);
            return;
        }
        updateStatus('Common, API, Web 已全部触发。');


        // --- 步骤 2: (新增) 从队列获取 Build 编号 ---
        setProgressActive(true, '步骤 2: 正在获取 Common 和 API 的构建编号...');

        const [commonBuild, apiBuild] = await Promise.all([
            getBuildNumberFromQueue('Common', commonQueueUrl, crumb),
            getBuildNumberFromQueue('API', apiQueueUrl, crumb)
        ]);

        if (!commonBuild || !apiBuild) {
            updateStatus("步骤 2 失败:无法获取 Common 或 API 的构建编号。构建链中止。", true);
            setProgressActive(false);
            return;
        }

        // --- 步骤 3: (新增) 轮询等待 Common 和 API 构建完成 ---
        updateStatus('步骤 3: 正在等待 Common 和 API 构建完成...');

        const [commonResult, apiResult] = await Promise.all([
            pollBuildStatus('Common', commonBuild, crumb),
            pollBuildStatus('API', apiBuild, crumb)
        ]);

        // 轮询结束,隐藏进度条
        setProgressActive(false);

        // --- 步骤 4: (新增) 检查构建结果 ---
        if (commonResult !== 'SUCCESS' || apiResult !== 'SUCCESS') {
            updateStatus(`步骤 4 失败:Common (结果: ${commonResult}) 或 API (结果: ${apiResult}) 构建失败。构建链中止。`, true);
            return;
        }

        updateStatus('步骤 4: Common 和 API 均已构建成功!');

        // --- 步骤 5: (沿用) 触发后续构建 ---
        // 注意:这里沿用旧逻辑,只是依次触发,并不等待它们完成
        // 如果你也想让它们按顺序等待完成,这里的逻辑需要改成
        // const billBuild = await getBuildNumberFromQueue(...);
        // const billResult = await pollBuildStatus(...);
        // 但目前,我们只按原样触发

        updateStatus('步骤 5: 正在触发 Bill Service ...');
        const billUrl = 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-bill-service/build?delay=0sec';
        // (注意:这里使用 triggerSingleBuild 只是为了"触发",并不等待它完成)
        let queueUrl = await triggerSingleBuild('Bill Service', billUrl, crumb);
        if (!queueUrl) {
            updateStatus("构建链因 Bill Service 触发失败而中止。", true);
            return;
        }

        updateStatus('步骤 6: 正在触发 Customer Service ...');
        const customerUrl = 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-customer-service/build?delay=0sec';
        queueUrl = await triggerSingleBuild('Customer Service', customerUrl, crumb);
        if (!queueUrl) {
            updateStatus("Customer Service 触发失败。构建链结束。", true);
            return;
        }

        updateStatus('步骤 7: 正在触发 System Service ...');
        const systemUrl = 'http://10.9.31.83:9001/job/sz-newcis-dev/job/sz-newcis-dev_cis-system-service/build?delay=0sec';
        queueUrl = await triggerSingleBuild('System Service', systemUrl, crumb);
        if (!queueUrl) {
            updateStatus("System Service 触发失败。构建链结束。", true);
            return;
        }

        updateStatus("联合构建链所有步骤已触发!(Common/API 已轮询,后续任务已触发)");
    }

    // 2. (已修改) 定义我们的 "主" 函数,用于创建和附加 UI 元素
    function createUI() {

        // 注入动画样式
        addStyles();

        // --- 1. 创建主悬浮容器 (页脚工具栏) ---
        const footerBar = document.createElement('div');
        footerBar.id = 'gm-footer-bar';
        footerBar.style = `
            position: fixed; bottom: 0; left: 0; width: 100%;
            padding: 8px 12px; background-color: #f0f0f0;
            border-top: 1px solid #ccc; z-index: 9997;
            display: flex; justify-content: space-between;
            align-items: center; box-sizing: border-box;
            font-family: Arial, sans-serif;
        `;

        // --- 2. 创建左侧容器 (用于按钮) ---
        const leftControls = document.createElement('div');
        leftControls.id = 'gm-left-controls';

        // --- 3. 创建右侧容器 (用于状态和进度条) ---
        const rightControls = document.createElement('div');
        rightControls.id = 'gm-right-controls';
        rightControls.style = `
            display: flex; flex-direction: column;
            width: 300px; /* 稍微加宽以显示更长的状态 */
        `;

        // --- 状态显示 ---
        statusDisplay = document.createElement('div');
        statusDisplay.id = 'gm-status-display';
        statusDisplay.style = `
            padding: 8px; background-color: #f0f0f0;
            border: 1px solid #ccc; border-radius: 4px;
            width: 100%; font-size: 13px; box-sizing: border-box;
            margin-bottom: 5px;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        `;
        statusDisplay.innerText = '准备就绪。';
        statusDisplay.title = '准备就绪。'; // 鼠标悬停显示完整消息

        // 重写 updateStatus 以便同时更新 title
        const oldUpdateStatus = updateStatus;
        updateStatus = (message, isError = false) => {
            oldUpdateStatus(message, isError);
            if(statusDisplay) statusDisplay.title = message;
        };


        // --- 进度条 (修改样式为不确定动画) ---
        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;
        `;
        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);

        // --- 按钮 ---
        const combinedButton = document.createElement('button');
        combinedButton.innerText = '▶ 启动联合构建';
        combinedButton.style = `
            padding: 8px 12px; color: white; border: none; border-radius: 4px;
            cursor: pointer; box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            text-align: left; background-color: #f0ad4e;
            box-sizing: border-box; font-size: 13px;
        `;
        combinedButton.onmouseover = function() { combinedButton.style.backgroundColor = '#ec971f'; };
        combinedButton.onmouseout = function() { combinedButton.style.backgroundColor = '#f0ad4e'; };
        combinedButton.onclick = startCombinedChain;

        // --- 4. 组装 DOM ---
        leftControls.appendChild(combinedButton);
        rightControls.appendChild(statusDisplay);
        rightControls.appendChild(progressContainer);
        footerBar.appendChild(leftControls);
        footerBar.appendChild(rightControls);
        document.body.appendChild(footerBar);

        updateStatus('v5.0 智能轮询已加载。');
    }

    // 3. 确保在 document.body 可用后才执行 UI 创建
    if (document.body) {
        createUI();
    } else {
        window.addEventListener('load', createUI);
    }

})();