261 lines
9.8 KiB
JavaScript
261 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* PERLER LAB · 验收通过 → 自动提交管道(T-080)
|
||
*
|
||
* 轮询 taskboard/tasks.json,检测新变为「验收通过」(approved) 的任务,
|
||
* 自动执行与 `node gitflow.js approve <id>` 等价的提交逻辑(git add -A + commit),
|
||
* 但【不推送】——推送由 GitOps 工程师单独执行(gitflow.js push / git push)。
|
||
*
|
||
* 用法:
|
||
* node auto-commit.js # 常驻轮询(默认每 60 秒,可用 Ctrl+C 停止)
|
||
* node auto-commit.js --once # 只巡检一轮后退出(适合定时任务/手动触发)
|
||
* node auto-commit.js --dry-run # 演练模式:只打印将要执行的操作,不提交、不改状态
|
||
*
|
||
* 环境变量:
|
||
* AUTO_COMMIT_INTERVAL 轮询间隔(秒,默认 60,最小 1)
|
||
* AUTO_COMMIT_TASKS 任务文件路径(默认 taskboard/tasks.json)
|
||
* AUTO_COMMIT_STATE 状态文件路径(默认 auto-commit-state.json)
|
||
* AUTO_COMMIT_LOG 日志文件路径(默认 auto-commit.log)
|
||
*
|
||
* 幂等性说明:
|
||
* - 状态文件记录每个任务「最近一次见到的状态」;
|
||
* - 仅当任务当前为 approved 且上次记录不是 approved 时才触发提交;
|
||
* - 首次运行只记录基线,不追溯提交已存在的验收通过任务;
|
||
* - 提交成功后把该任务标记为 approved,重复运行不会二次提交。
|
||
*/
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { execSync } = require('child_process');
|
||
|
||
const ROOT = path.resolve(__dirname);
|
||
const TASKS_FILE = process.env.AUTO_COMMIT_TASKS || path.join(ROOT, 'taskboard', 'tasks.json');
|
||
const STATE_FILE = process.env.AUTO_COMMIT_STATE || path.join(ROOT, 'auto-commit-state.json');
|
||
const LOG_FILE = process.env.AUTO_COMMIT_LOG || path.join(ROOT, 'auto-commit.log');
|
||
const INTERVAL_SEC = Math.max(1, Number(process.env.AUTO_COMMIT_INTERVAL) || 60);
|
||
|
||
const ARGS = process.argv.slice(2);
|
||
const DRY_RUN = ARGS.includes('--dry-run');
|
||
const ONCE = ARGS.includes('--once');
|
||
|
||
const TZ_OFFSET_MS = 8 * 60 * 60 * 1000; // 中国标准时间 UTC+8
|
||
|
||
/* ---------- 基础工具 ---------- */
|
||
|
||
function now() {
|
||
return new Date(Date.now() + TZ_OFFSET_MS).toISOString().replace('Z', '+08:00');
|
||
}
|
||
function ts() {
|
||
return now().slice(0, 19).replace('T', ' ');
|
||
}
|
||
|
||
/** 输出到控制台 + 追加写日志文件;日志写失败不影响主流程 */
|
||
function log(msg) {
|
||
const line = `[${ts()}] ${msg}`;
|
||
console.log(line);
|
||
try {
|
||
fs.appendFileSync(LOG_FILE, line + '\n', 'utf8');
|
||
} catch (e) {
|
||
console.error(`[${ts()}] [日志写入失败] ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** 执行 shell 命令,返回 { ok, out };不抛异常 */
|
||
function run(cmd, silent = true) {
|
||
try {
|
||
const out = execSync(cmd, {
|
||
cwd: ROOT,
|
||
encoding: 'utf8',
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
windowsHide: true,
|
||
});
|
||
return { ok: true, out: (out || '').trim() };
|
||
} catch (e) {
|
||
const msg = ((e.stderr || e.stdout || e.message) || '').trim();
|
||
if (!silent && msg) console.log(msg);
|
||
return { ok: false, out: msg };
|
||
}
|
||
}
|
||
|
||
/** 与 gitflow.js 相同的默认提交身份兜底 */
|
||
function ensureGitUser() {
|
||
const name = run('git config user.name').out;
|
||
const email = run('git config user.email').out;
|
||
if (!name) run('git config user.name "Sisyphus"');
|
||
if (!email) run('git config user.email "sisyphus@perler-lab.local"');
|
||
}
|
||
|
||
function gitStatus() {
|
||
const r = run('git status --porcelain');
|
||
if (!r.ok) return { ok: false, all: [] };
|
||
const all = r.out ? r.out.split('\n').filter(Boolean) : [];
|
||
return { ok: true, all };
|
||
}
|
||
|
||
/* ---------- 状态文件(auto-commit-state.json) ---------- */
|
||
|
||
function loadState() {
|
||
if (!fs.existsSync(STATE_FILE)) return { lastSeen: {}, updatedAt: null };
|
||
try {
|
||
const s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||
if (!s || typeof s !== 'object' || !s.lastSeen) return { lastSeen: {}, updatedAt: s && s.updatedAt ? s.updatedAt : null };
|
||
return s;
|
||
} catch (e) {
|
||
log(`[警告] 状态文件解析失败(${e.message}),本轮按全新状态处理`);
|
||
return { lastSeen: {}, updatedAt: null };
|
||
}
|
||
}
|
||
|
||
function saveState(state) {
|
||
const tmp = path.join(path.dirname(STATE_FILE), `.auto-commit-state.${process.pid}.tmp`);
|
||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
||
fs.renameSync(tmp, STATE_FILE);
|
||
}
|
||
|
||
/* ---------- 任务文件 ---------- */
|
||
|
||
function loadTasks() {
|
||
if (!fs.existsSync(TASKS_FILE)) throw new Error(`任务文件不存在: ${TASKS_FILE}`);
|
||
const db = JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8'));
|
||
if (!db || !Array.isArray(db.tasks)) throw new Error(`任务文件格式错误: 缺少 tasks 数组(${TASKS_FILE})`);
|
||
return db.tasks;
|
||
}
|
||
|
||
/* ---------- 提交(等价 gitflow.js approve 的提交部分,不含推送) ---------- */
|
||
|
||
/**
|
||
* 提交一个已验收通过的任务。
|
||
* 返回 { result: 'committed'|'skip-clean'|'error', msg, detail, hash }
|
||
* - committed : 提交成功,hash 为完整 commit hash
|
||
* - skip-clean: 工作区无改动,无需提交(不视为错误)
|
||
* - error : 提交失败,调用方应保留状态以便下轮重试
|
||
*/
|
||
function commitTask(task) {
|
||
const msg = `[验收通过] ${task.id} ${task.title}`;
|
||
|
||
const st = gitStatus();
|
||
if (!st.ok) return { result: 'error', msg, detail: 'git status 执行失败(非 git 仓库?)' };
|
||
if (!st.all.length) return { result: 'skip-clean', msg, detail: '工作区无未提交改动,跳过' };
|
||
|
||
if (!run('git add -A').ok) return { result: 'error', msg, detail: 'git add -A 执行失败' };
|
||
|
||
// 用临时文件承载提交信息,避免中文/引号在 shell 中转义出错
|
||
const tmp = path.join(os.tmpdir(), `auto-commit-${process.pid}-${Date.now()}.txt`);
|
||
try {
|
||
fs.writeFileSync(tmp, msg, 'utf8');
|
||
} catch (e) {
|
||
return { result: 'error', msg, detail: `提交信息临时文件写入失败: ${e.message}` };
|
||
}
|
||
const r = run(`git commit -F "${tmp}"`);
|
||
try { fs.unlinkSync(tmp); } catch (e) { /* 忽略清理失败 */ }
|
||
|
||
if (!r.ok) {
|
||
if (/nothing to commit|no changes added|nothing added to commit/i.test(r.out)) {
|
||
return { result: 'skip-clean', msg, detail: '提交时已无改动(可能被其他进程先提交),跳过' };
|
||
}
|
||
return { result: 'error', msg, detail: `git commit 失败: ${r.out}` };
|
||
}
|
||
|
||
const h = run('git rev-parse HEAD');
|
||
return { result: 'committed', msg, detail: r.out, hash: h.ok ? h.out : '(无法获取)' };
|
||
}
|
||
|
||
/* ---------- 巡检主流程 ---------- */
|
||
|
||
function processCycle() {
|
||
const state = loadState();
|
||
const tasks = loadTasks();
|
||
const approved = tasks.filter(t => t.status === 'approved');
|
||
|
||
// 首次运行:只记录基线,不追溯提交(防止一次性把历史验收任务全部提交)
|
||
if (state.updatedAt == null) {
|
||
for (const t of tasks) state.lastSeen[t.id] = t.status;
|
||
log(`[初始化] 首次运行:将记录 ${tasks.length} 个任务的当前状态为基线(其中验收通过 ${approved.length} 个,不追溯提交)`);
|
||
log(`[初始化] 此后新变为「验收通过」的任务将自动提交`);
|
||
if (DRY_RUN) {
|
||
log('[演练] 演练模式不写状态文件、不做任何提交');
|
||
return;
|
||
}
|
||
state.updatedAt = now();
|
||
saveState(state);
|
||
return;
|
||
}
|
||
|
||
const candidates = approved.filter(t => state.lastSeen[t.id] !== 'approved');
|
||
|
||
if (!candidates.length) {
|
||
log(`[巡检] 无新验收通过任务(任务总数 ${tasks.length},验收通过 ${approved.length})`);
|
||
} else {
|
||
log(`[检测] 发现 ${candidates.length} 个新验收通过任务: ${candidates.map(c => c.id).join(', ')}`);
|
||
}
|
||
|
||
const failIds = new Set();
|
||
for (const t of candidates) {
|
||
if (DRY_RUN) {
|
||
const st = gitStatus();
|
||
const clean = !st.ok || !st.all.length;
|
||
log(`[演练] 任务 ${t.id}「${t.title}」→ 将执行提交,提交信息: "[验收通过] ${t.id} ${t.title}"` +
|
||
(clean ? '(当前工作区无改动,实际运行会跳过)' : `(当前工作区 ${st.all.length} 个文件待提交)`));
|
||
continue;
|
||
}
|
||
const r = commitTask(t);
|
||
if (r.result === 'committed') {
|
||
log(`[提交成功] 任务 ${t.id}「${t.title}」→ commit ${r.hash}`);
|
||
} else if (r.result === 'skip-clean') {
|
||
log(`[跳过] 任务 ${t.id}「${t.title}」验收通过但无需提交: ${r.detail}`);
|
||
} else {
|
||
log(`[提交失败] 任务 ${t.id}「${t.title}」: ${r.detail}(保留状态,下轮自动重试)`);
|
||
failIds.add(t.id);
|
||
}
|
||
}
|
||
|
||
if (DRY_RUN) {
|
||
log('[演练] 演练模式不写状态文件、不做任何提交');
|
||
return;
|
||
}
|
||
|
||
// 同步 lastSeen:提交失败的任务保留旧状态以便重试,其余记录当前状态
|
||
for (const t of tasks) {
|
||
if (failIds.has(t.id)) continue;
|
||
state.lastSeen[t.id] = t.status;
|
||
}
|
||
state.updatedAt = now();
|
||
try {
|
||
saveState(state);
|
||
} catch (e) {
|
||
log(`[错误] 状态文件写入失败: ${e.message}(下轮重试)`);
|
||
}
|
||
}
|
||
|
||
/* ---------- 入口 ---------- */
|
||
|
||
function main() {
|
||
ensureGitUser();
|
||
const gitOk = run('git rev-parse --is-inside-work-tree');
|
||
if (!gitOk.ok) {
|
||
log(`[错误] 当前目录不是 git 仓库,自动提交管道退出: ${gitOk.out || gitOk.out === '' ? '(git 不可用)' : gitOk.out}`);
|
||
process.exit(1);
|
||
}
|
||
log(`[启动] 验收自动提交管道 v1.0${DRY_RUN ? '(演练模式:只预览不提交)' : ''}${ONCE ? '(单次模式)' : ''}`);
|
||
log(`[启动] 任务文件: ${TASKS_FILE}`);
|
||
log(`[启动] 状态文件: ${STATE_FILE} | 日志: ${LOG_FILE} | 轮询间隔: ${INTERVAL_SEC}s | 推送: 不自动推送`);
|
||
|
||
const runCycle = () => {
|
||
try {
|
||
processCycle();
|
||
} catch (e) {
|
||
log(`[错误] 巡检异常: ${e.message}(本轮跳过,下轮继续)`);
|
||
}
|
||
if (ONCE) {
|
||
log('[结束] 单次模式巡检完成');
|
||
return;
|
||
}
|
||
setTimeout(runCycle, INTERVAL_SEC * 1000);
|
||
};
|
||
runCycle();
|
||
}
|
||
|
||
main();
|