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