Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
import { spawnSync } from 'child_process';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||
import { line, ok, warn } from './log.js';
|
||
|
||
const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||
|
||
/**
|
||
* 建立一個同步執行 git 子行程的 runner。透過注入 `spawn` 以利測試
|
||
* (正式環境傳入 `child_process.spawnSync`,測試可傳入 stub)。
|
||
*
|
||
* 回傳的 `run(args, cwd, env)` 會以 utf8 編碼執行 `git <args>`,
|
||
* 成功回傳經 trim 的 stdout,失敗則丟出 Error。
|
||
*
|
||
* @param {(cmd: string, args: string[], opts: object) => {error?: Error & {code?: string}, status?: number, stdout?: string, stderr?: string}} spawn
|
||
* 同步 spawn 實作(依賴注入,通常為 `spawnSync`)。
|
||
* @returns {(args: string[], cwd?: string, env?: object) => string}
|
||
* 執行 git 的函式:回傳 trim 後的 stdout。
|
||
* @throws {Error} 找不到 git 指令時(`ENOENT`)丟出中文提示。
|
||
* @throws {Error} git 子行程本身的 `error`(非 ENOENT)原樣丟出。
|
||
* @throws {Error} git 離開碼非 0 時,以 stderr/stdout 內容丟出。
|
||
*/
|
||
function makeRunner(spawn) {
|
||
return function run(args, cwd, env) {
|
||
const opts = { cwd, encoding: 'utf8' };
|
||
if (env) opts.env = env;
|
||
const result = spawn('git', args, opts);
|
||
if (result.error) {
|
||
if (result.error.code === 'ENOENT') throw new Error('找不到 git 指令,請確認 action image 已安裝 git');
|
||
throw result.error;
|
||
}
|
||
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
|
||
return (result.stdout || '').trim();
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 包裝一段需要 git HTTP 認證的工作:先在 workspace 寫出暫時的
|
||
* `.git-askpass.sh`(透過 `GIT_ASKPASS` 提供 token),呼叫 `fn(credEnv)`,
|
||
* 再清除該暫存腳本。
|
||
*
|
||
* 清理時機會依 `fn` 回傳型別自動判斷:
|
||
* 同步回傳會立即清理;回傳 Promise(含 async 回呼)則延後到 Promise
|
||
* settle 後才清理,避免在第一個 await 就刪掉腳本,導致後續 git push
|
||
* 因 `cannot exec .git-askpass.sh` 而失敗。
|
||
*
|
||
* @template T
|
||
* @param {string} workspace 寫入暫存 askpass 腳本的目錄。
|
||
* @param {(credEnv: NodeJS.ProcessEnv) => T} fn 帶入憑證環境變數執行的回呼。
|
||
* @returns {T} 即 `fn` 的回傳值(Promise 會被包成 `.finally(cleanup)` 後回傳)。
|
||
* @throws 透傳 `fn` 丟出的任何例外(同步路徑會先清理暫存腳本再 re-throw)。
|
||
* @remarks askpass 腳本以權限 0o700 寫出;token 取自 config 的 `GITEA_TOKEN`。
|
||
*/
|
||
function withAskpass(workspace, fn) {
|
||
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
||
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
||
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
||
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
|
||
let result;
|
||
try {
|
||
result = fn(credEnv);
|
||
} catch (e) {
|
||
cleanup();
|
||
throw e;
|
||
}
|
||
// Defer cleanup until an async callback settles, otherwise the askpass script
|
||
// is deleted at the first `await` and later network ops (e.g. git push) fail
|
||
// with "cannot exec .git-askpass.sh". Sync callbacks clean up immediately.
|
||
if (result && typeof result.then === 'function') {
|
||
return result.finally(cleanup);
|
||
}
|
||
cleanup();
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 以容錯方式執行 git 讀取指令:成功回傳 trim 後的輸出,
|
||
* 任何錯誤都吞掉並回傳空字串。適用於「失敗也不該中斷流程」的唯讀查詢
|
||
* (例如取 HEAD SHA、分支名、commit 時間)。
|
||
*
|
||
* @param {(args: string[], cwd?: string, env?: object) => string} run
|
||
* 由 `makeRunner` 產生的 git 執行函式。
|
||
* @param {string[]} args git 子指令與參數。
|
||
* @param {string} [cwd] 執行目錄。
|
||
* @param {object} [env] 環境變數覆寫。
|
||
* @returns {string} git 的 trim 輸出;失敗時回傳空字串。
|
||
* @remarks 不會拋出例外,也不記錄錯誤。
|
||
*/
|
||
function readGitOutput(run, args, cwd, env) {
|
||
try {
|
||
return run(args, cwd, env);
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 讀取指定 repo 目錄的目前狀態(HEAD SHA、短 SHA、目前分支、commit 時間)。
|
||
* 所有查詢皆採容錯讀取,任一失敗對應欄位即為空字串,不會丟出例外。
|
||
*
|
||
* @param {string} repoDir git 工作目錄路徑。
|
||
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||
* 測試用依賴注入:覆寫底層的同步 spawn 實作。
|
||
* @returns {{repoDir: string, branch: string, headSha: string, shortSha: string, commitTime: string}}
|
||
* repo 狀態快照;無法取得的欄位為空字串。
|
||
* @remarks `commitTime` 為 `%cI` 格式(committer date, ISO 8601 嚴格格式)。
|
||
*/
|
||
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
||
const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir);
|
||
const branch = readGitOutput(run, ['branch', '--show-current'], repoDir);
|
||
const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir);
|
||
return { repoDir, branch, headSha, shortSha, commitTime };
|
||
}
|
||
|
||
/**
|
||
* 取得 HEAD commit 的完整 commit message(`%B`,含 subject 與 body)。
|
||
* 容錯讀取:失敗時回傳空字串。
|
||
*
|
||
* @param {string} repoDir git 工作目錄路徑。
|
||
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||
* 測試用依賴注入。
|
||
* @returns {string} HEAD 的完整 commit 訊息;失敗時為空字串。
|
||
*/
|
||
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
|
||
}
|
||
|
||
/**
|
||
* 判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit
|
||
* (commit message 含 `BOT_COMMIT_MARKER`)。常用於避免機器人 commit
|
||
* 反覆觸發新一輪審查。
|
||
*
|
||
* @param {string} repoDir git 工作目錄路徑。
|
||
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||
* 測試用依賴注入。
|
||
* @returns {boolean} HEAD 訊息含機器人標記時為 true;讀取失敗時安全地回傳 false。
|
||
*/
|
||
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
||
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||
}
|
||
|
||
/**
|
||
* 用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,
|
||
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
|
||
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
|
||
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
|
||
*/
|
||
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
try {
|
||
return withAskpass(workspace, credEnv => {
|
||
run(['ls-remote', remoteUrl, PR_HEAD_BRANCH || 'HEAD'], workspace, credEnv);
|
||
return { ok: true };
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clone PR head branch to workspace/repo (idempotent)
|
||
*/
|
||
export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
const repoDir = path.join(workspace, 'repo');
|
||
|
||
return withAskpass(workspace, credEnv => {
|
||
if (!fs.existsSync(repoDir)) {
|
||
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
||
ok(`repo cloned to ${repoDir}`);
|
||
} else {
|
||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
||
ok('repo already exists, fetched latest');
|
||
}
|
||
return repoDir;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 將 AI 審查產出的 review 檔(findings / exclusions)結轉到 repo,並 commit、
|
||
* push 回 PR head branch。流程:設定機器人 git 身分 → fetch + hard reset 對齊
|
||
* 遠端 → 從 workspace 複製存在的 review 檔到 repo 並 add → 若無變更則跳過 →
|
||
* 以含 `BOT_COMMIT_MARKER` 與結果標籤的訊息 commit → push。
|
||
*
|
||
* 失敗策略:push 失敗只記 warning(commit 已在本地完成);其餘步驟的例外
|
||
* 由外層捕捉並記 warning,函式整體**不丟出例外**,以免中斷上層流程。
|
||
*
|
||
* @param {string} workspace review 檔來源目錄、askpass 腳本所在目錄。
|
||
* @param {string} repoDir 目標 git repo 目錄(commit/push 的工作目錄)。
|
||
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||
* 測試用依賴注入:覆寫底層同步 spawn。
|
||
* @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數;
|
||
* 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。
|
||
* @param {'success'|'failure'} [reviewOutcome='success']
|
||
* 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。
|
||
* @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。
|
||
* @remarks `git reset --hard origin/<branch>` 會丟棄本地未對齊變更,請確認
|
||
* review 檔是在 reset 之後才複製進來(流程已如此安排)。
|
||
*/
|
||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
|
||
const run = makeRunner(_spawnSync);
|
||
|
||
try {
|
||
await withAskpass(workspace, async credEnv => {
|
||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||
if (PR_HEAD_BRANCH) {
|
||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
|
||
}
|
||
|
||
const reviewFilePaths = REVIEW_FILE_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
||
if (reviewFilePaths.length > 0) {
|
||
for (const relPath of reviewFilePaths) {
|
||
const src = path.join(workspace, relPath);
|
||
const dest = path.join(repoDir, relPath);
|
||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||
fs.copyFileSync(src, dest);
|
||
}
|
||
run(['add', ...reviewFilePaths], repoDir);
|
||
}
|
||
|
||
const status = run(['status', '--porcelain'], repoDir);
|
||
if (!status) {
|
||
line('review files 無變更,跳過 commit');
|
||
return;
|
||
}
|
||
|
||
const outcomeTag = reviewOutcome === 'failure' ? '[failure]' : '[success]';
|
||
const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}${outcomeTag}`], repoDir);
|
||
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||
try {
|
||
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||
ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
|
||
} catch (pushErr) {
|
||
warn(`Step8 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
warn(`Runner failed: commit/push 失敗: ${e.message}`);
|
||
}
|
||
}
|