Files
perler-lab/auto-commit.js

261 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();