Files
ai-code-review/app/git.js
T

264 lines
12 KiB
JavaScript
Raw 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.
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, GITEA_COMMENT_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 帶入憑證環境變數執行的回呼。
* @param {string} [token=GITEA_TOKEN] 供 git HTTP 認證使用的 token。預設為自動的
* `GITEA_TOKEN`(適用於唯讀的 clone/ls-remote);需要讓 push 出來的 commit
* 重新觸發 workflow 時,呼叫端應改傳真人 PAT`GITEA_COMMENT_TOKEN`),因為
* Gitea 不會為「自動 token」推送的 commit 發出事件。
* @returns {T} 即 `fn` 的回傳值(Promise 會被包成 `.finally(cleanup)` 後回傳)。
* @throws 透傳 `fn` 丟出的任何例外(同步路徑會先清理暫存腳本再 re-throw)。
* @remarks askpass 腳本以權限 0o700 寫出;token 由參數帶入並透過 `GIT_TOKEN` 提供給腳本。
*/
function withAskpass(workspace, fn, token = GITEA_TOKEN) {
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: 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 之後才複製進來(流程已如此安排)。
* @remarks push 優先使用真人 PAT `GITEA_COMMENT_TOKEN`(無則退回 `GITEA_TOKEN`),
* 目的是讓 bot commit 能重新觸發 PR 的 workflow:以自動 token 推送的 commit
* 不會發出 `synchronize` 事件,新 head commit 便拿不到檢查而卡住。改用 PAT
* 推送後會正常重觸發,重跑時由 main.js Step3 偵測 `[ai-review-bot]` 標記後跳過。
*/
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
const run = makeRunner(_spawnSync);
const pushToken = GITEA_COMMENT_TOKEN || GITEA_TOKEN;
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}`);
}
}, pushToken);
} catch (e) {
warn(`Runner failed: commit/push 失敗: ${e.message}`);
}
}