Files
ai-code-review/src/lib/gitrepo.js
T
JefferyandClaude Opus 4.8 05178be520
node-actions/template: CI / BUILD (pull_request) Successful in 5s
CI / TEST (Claude) (pull_request) Successful in 28s
CI / TEST (Antigravity) (pull_request) Successful in 57s
CI / TEST (Codex) (pull_request) Successful in 3m10s
fix(推送觸發 CI): 合併 push-token 進 token,findings 一律以 token 認證推送觸發 CI
- 移除 push-token input,token 兼作 Gitea API 認證與 findings 推送
- commitAndPushFindings 一律以 token 明確認證推送(不走 origin 自動 token);
  只要 token 為能觸發 CI 的 PAT,結果 commit 即再觸發 PR 的 CI,避免新 head 缺檢查卡合併
- ci.yaml 三個 job 移除 push-token,保留 token: ${{ secrets.TOKEN }}

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:24:18 +08:00

322 lines
16 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.
'use strict';
const { execFileSync } = require('child_process');
// git 操作工具:一律以 execFileSync 呼叫 git(不經 shell,避免注入),輸出以 UTF-8 回傳。
/**
* 同步執行 git 指令並回傳原始 stdout 輸出。
*
* 一律以 execFileSync 直接呼叫 git(不經 shell),避免命令注入;
* 輸出以 UTF-8 字串回傳,且不做任何 trim,保留原樣(含結尾換行)。
* stdout 上限為 64 MiB,足以容納大型 diff。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {...string} args - 傳給 git 的參數(子指令與旗標),逐一作為獨立引數傳入,不會被 shell 解析。
* @returns {string} git 指令的原始 stdoutUTF-8 字串,未 trim)。
* @throws {Error} git 以非零狀態碼結束、找不到 git 執行檔、或輸出超過 64 MiB 時,由 execFileSync 同步拋出。
* @remarks
* 使用情境:作為本模組所有 git 操作的共用底層,例如
* `git(cwd, 'diff', base, 'HEAD', '--', file)` 取得單檔 diff
* 需要去除前後空白的結果時請改用 gitTrim。
* 本函式未匯出,僅供模組內部使用。
*/
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
}
/**
* 同步執行 git 指令並回傳去除前後空白的 stdout 輸出。
*
* 為 git() 的薄包裝:執行結果做 trim(),適合取得單一值型輸出
* commit SHA、標題、ISO 時間等),避免結尾換行混入後續處理。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {...string} args - 傳給 git 的參數(子指令與旗標),逐一作為獨立引數傳入,不會被 shell 解析。
* @returns {string} git 指令 stdout 去除前後空白後的字串。
* @throws {Error} 底層 git() 執行失敗時原樣拋出(不做任何攔截)。
* @remarks
* 使用情境:`gitTrim(cwd, 'rev-parse', 'HEAD')` 取得目前 HEAD 的 commit SHA
* 供 commitAndPushFindings 比對是否需要先 checkout 到 PR head。
* 本函式未匯出,僅供模組內部使用。
*/
function gitTrim(cwd, ...args) {
return git(cwd, ...args).trim();
}
/**
* 嘗試同步執行 git 指令,失敗時回傳 false,成功時回傳 true。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {...string} args - 傳給 git 的參數。
* @returns {boolean} git 指令是否成功結束。
* @remarks
* 使用情境:修復淺層 checkout 的歷史不足時,部分 fetch 策略可能因 runner
* 或遠端版本不同而失敗;呼叫端可依序嘗試多種策略,不讓第一個失敗中斷流程。
*/
function tryGit(cwd, ...args) {
try {
git(cwd, ...args);
return true;
} catch {
return false;
}
}
/**
* 取得目前 HEAD 最新一筆 commit 的訊息標題(commit message 第一行)。
*
* 等同執行 `git log -1 --pretty=%s` 並去除前後空白。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @returns {string} 最新 commit 的標題(subject);不含訊息本文。
* @throws {Error} cwd 不是 git repo 或 repo 尚無任何 commit 時,底層 git 執行失敗並拋出。
* @remarks
* 使用情境:AI code review 流程(src/index.js 步驟 1)依最新 commit 標題判斷
* 本次觸發是否為 ai-review-bot 自身的結果 commit[success]/[failure]),
* 是則直接回報對應狀態、避免重複審查。
*/
function latestCommitSubject(cwd) {
return gitTrim(cwd, 'log', '-1', '--pretty=%s');
}
/**
* 解析 PR base 分支與目前 HEAD 的 merge-base commit SHA。
*
* 先以 refspec 明確更新 `origin/<baseRef>`,再以 `git merge-base origin/<baseRef> HEAD`
* 取得共同祖先。若 checkout 是淺層歷史而導致 merge-base 失敗,會依序補抓更完整的
* base/head 歷史;**每個補抓策略成功後立即重試 merge-base,一成功即回傳**
* 避免在已補到足夠歷史後仍多做無謂的 fetch 往返(例如 `--unshallow` 成功就不再 deepen)。
* 各策略採資料驅動依序執行;全部用盡仍失敗時,丟出彙整了「哪個策略成功/失敗」診斷的錯誤,
* 方便維護者判斷是哪一步補抓不足(診斷僅含策略名與成敗,不含 git 原始輸出以免洩漏遠端資訊)。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} baseRef - PR 目標(base)分支名稱,例如 'master' 或 'develop';不含 'origin/' 前綴。
* @returns {string} merge-base 的 commit SHA40 碼十六進位字串)。
* @throws {Error} 補抓歷史後仍無法取得共同祖先時,丟出含 baseRef 與各策略診斷的明確錯誤
* `error.cause` 保留首次 merge-base 失敗的原始錯誤)。
* @remarks
* 使用情境:AI code review 以此結果作為 diff 比較基準——
* 先 `resolveMergeBase(cwd, pr.base.ref)` 取得基準 SHA
* 再傳給 changedFiles / fileDiff 只審查 PR 實際引入的變更,
* 避免把 base 分支後續演進誤算進 diff。
*/
function resolveMergeBase(cwd, baseRef) {
const remoteBase = `origin/${baseRef}`;
const diagnostics = [];
// 執行一個 fetch 策略並記錄成敗(只記策略名與成敗,不含 git 原始輸出,避免洩漏遠端資訊)。
const runFetch = (label, ...args) => {
const ok = tryGit(cwd, ...args);
diagnostics.push(`${label}${ok ? '成功' : '失敗'}`);
return ok;
};
// 每個補抓策略後重試 merge-base:成功回傳 SHA,失敗記診斷並回傳 null。
const tryMergeBase = (label) => {
try {
return gitTrim(cwd, 'merge-base', remoteBase, 'HEAD');
} catch {
diagnostics.push(`merge-base${label}):失敗`);
return null;
}
};
// 先明確更新 origin/<baseRef>,再嘗試 merge-base。
runFetch(`fetch base(${baseRef})`, 'fetch', '--no-tags', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`);
let firstError;
try {
return gitTrim(cwd, 'merge-base', remoteBase, 'HEAD');
} catch (err) {
firstError = err;
diagnostics.push('merge-base(首次):失敗');
}
// 資料驅動的補抓策略:淺層才 unshallow;其後依序 deepen base 與 HEAD。
// 每個策略成功後立即重試 merge-base,成功即回傳,避免多餘往返。
const strategies = [];
if (gitTrim(cwd, 'rev-parse', '--is-shallow-repository') === 'true') {
strategies.push(['unshallow', 'fetch', '--no-tags', '--unshallow', 'origin']);
}
strategies.push([
'deepen base',
'fetch', '--no-tags', '--deepen=1000', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`,
]);
strategies.push(['deepen HEAD', 'fetch', '--no-tags', '--deepen=1000', 'origin', 'HEAD']);
for (const [label, ...args] of strategies) {
if (!runFetch(label, ...args)) continue; // fetch 失敗就換下一個策略。
const sha = tryMergeBase(`${label} 後`);
if (sha) return sha;
}
const error = new Error(
`無法解析 origin/${baseRef} 與 HEAD 的 merge-base;請確認 checkout 有足夠歷史,或設定 checkout fetch-depth: 0。診斷:${diagnostics.join('')}`,
);
error.cause = firstError;
throw error;
}
/**
* 列出 base 與 HEAD 之間有變更的檔案清單。
*
* 等同執行 `git diff --name-only <base> HEAD`,將輸出依行切割為陣列;
* 路徑為相對 repo 根目錄的格式。無任何變更時回傳空陣列。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} base - 比較基準的 commit SHA 或 ref(通常為 resolveMergeBase 的回傳值)。
* @returns {string[]} 有變更的檔案路徑陣列(相對 repo 根目錄);無變更時為空陣列。
* @throws {Error} base 不是有效的 commit/ref 時,底層 git 執行失敗並拋出。
* @remarks
* 使用情境:AI code review 先以 resolveMergeBase 取得基準 SHA
* 再呼叫 changedFiles 取得 PR 變更檔案清單,逐檔用 fileDiff 取得 diff 內容送審。
*/
function changedFiles(cwd, base) {
return gitTrim(cwd, 'diff', '--name-only', base, 'HEAD')
.split('\n')
.filter(Boolean);
}
/**
* 取得單一檔案在 base 與 HEAD 之間的 git diff 內容。
*
* 等同執行 `git diff <base> HEAD -- <file>`,回傳原始 unified diff 文字(不做 trim)。
* 以 `--` 分隔 ref 與路徑,避免檔名被誤判為 ref。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} base - 比較基準的 commit SHA 或 ref(通常為 resolveMergeBase 的回傳值)。
* @param {string} file - 目標檔案路徑(相對 repo 根目錄,通常來自 changedFiles 的結果)。
* @returns {string} 該檔案的 unified diff 原始文字;檔案無變更時為空字串。
* @throws {Error} base 不是有效的 commit/ref 時,底層 git 執行失敗並拋出。
* @remarks
* 使用情境:AI code review 逐檔取得 diff——對 changedFiles 回傳的每個路徑
* 呼叫 fileDiff,將 diff 內容組進送給 AI 模型的審查 prompt。
*/
function fileDiff(cwd, base, file) {
return git(cwd, 'diff', base, 'HEAD', '--', file);
}
/**
* 取得檔案最後一次 commit 的時間(ISO 8601 格式)。
*
* 等同執行 `git log -1 --format=%cI -- <file>`,回傳 committer date
* 的嚴格 ISO 8601 字串(含時區位移,例如 2026-07-17T10:30:00+08:00)。
* 查不到時(git 執行失敗、或檔案從未被 commit)一律回傳空字串,不拋出例外。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} file - 目標檔案路徑(相對 repo 根目錄)。
* @returns {string} 最後一次 commit 的 ISO 8601 時間字串;查不到或執行失敗時為空字串。
* @remarks
* 使用情境:產生 review findings 或變更摘要留言時,標註變更檔案在 git 歷史中的
* 最後更新時間;回傳空字串代表無法取得,呼叫端應自行處理此情形(例如以「—」佔位)。
*/
function fileLastUpdatedIso(cwd, file) {
try {
return gitTrim(cwd, 'log', '-1', '--format=%cI', '--', file);
} catch {
return '';
}
}
/**
* 以 ai-review-bot 身分將指定檔案 commit 並 push 回 PR 的來源(head)分支;
* 暫存後與 HEAD 無差異(沒東西可 commit)時不建立空 commit,直接回傳 false。
*
* 若目前 HEAD 不在 PR head commit(例如 checkout 停在 merge commit),
* 會先 `git checkout --detach <headSha>` 站上 head,避免把 merge 內容推回來源分支。
* commit 以 `-c` 臨時覆寫 user.name / user.email,不改動 repo 的 git 設定。
* push 策略:一律以 `token` 的身分明確認證推送({@link pushWithCredential},不走 runner 的
* origin 自動 token)——origin 帶的自動 tokengitea.token / GITHUB_TOKEN)推送不會再觸發 CI
* 改以呼叫端提供的 `token`(建議為 PAT)身分推送,才會讓 PR 的 synchronize 事件再觸發 CI。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {object} options - 提交與推送設定。
* @param {string} options.headRef - PR 來源(head)分支名稱,push 目標為 `refs/heads/<headRef>`;不含 'refs/heads/' 前綴。
* @param {string} [options.headSha] - PR head 的 commit SHA;有提供且與目前 HEAD 不同時會先 detach 到此 commit。可省略(falsy 時不 detach,直接於目前 HEAD 上 commit)。
* @param {string} options.message - commit 訊息。
* @param {string[]} options.files - 要加入 commit 的檔案路徑清單(相對 repo 根目錄);全數無實際變更時不 commit、回傳 false。
* @param {string} options.token - 具該 repo push 權限的 Gitea access token(建議為能觸發 CI 的 PAT);用於 findings commit 的認證推送。
* @param {string} options.serverUrl - Gitea 伺服器根網址(例如 https://gitea.example.com),須為合法 URL。
* @param {string} options.repository - repo 完整名稱(owner/repo 格式),與 serverUrl 組成 clone URL。
* @returns {boolean} true=有變更且已 commit 並 push 到來源分支;false=暫存區與 HEAD 無差異,略過 commit/push。
* @throws {Error} checkout / add / commit / 重試 push 失敗時拋出;serverUrl 非合法 URL 時 new URL() 拋出 TypeError。
* @remarks
* 使用情境:AI code review 完成後,`commitFindings`src/index.js)以本函式將
* findings 檔與 `.gitea/ai-review/exclusions.json` 等結果檔提交回 PR 來源分支,
* 並依回傳值記錄「已 commit/push」或「無實際變更、略過」的不同日誌。
*
* 安全注意:帶認證的推送一律透過 {@link pushWithCredential} 進行——認證只以
* 環境變數(`http.<url>.extraheader` 的 base64 Basic)傳入,**不進 argv**
* 推送目標 URL 本身不含帳密;且 push 失敗時改拋固定訊息,避免 `execFileSync`
* 例外把命令列(含 token)回顯到 CI log 或程序清單。
*/
function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, serverUrl, repository }) {
const current = gitTrim(cwd, 'rev-parse', 'HEAD');
if (headSha && current !== headSha) {
git(cwd, 'checkout', '--detach', headSha);
}
git(cwd, 'add', '--', ...files);
try {
git(cwd, 'diff', '--cached', '--quiet');
return false; // 暫存區與 HEAD 無差異 → 沒東西可 commit。
} catch {
// 有暫存變更 → 繼續 commit。
}
git(
cwd,
'-c', 'user.name=ai-review-bot',
'-c', 'user.email=ai-review-bot@noreply.gitea',
'commit', '-m', message,
);
const refspec = `HEAD:refs/heads/${headRef}`;
const remoteUrl = `${serverUrl}/${repository}.git`;
// 一律以 token 的身分明確認證推送(不走 origin 的自動 token)——只要 token 是能觸發 CI 的 PAT
// 結果 commit 就會讓 PR 的 synchronize 事件再觸發 CI,由步驟 1 快速回報把結果蓋到新 head。
pushWithCredential(cwd, remoteUrl, token, refspec);
return true;
}
/**
* 以帶認證的方式推送到指定遠端,認證資訊只經環境變數傳入、不進命令列 argv。
*
* 認證方式:等同 `https://ai-review-bot:<secret>@host/...` 的 HTTP Basicgit 會把
* URL 帳密轉成相同的 `Authorization: Basic` 標頭送出),但改以 git 的
* `GIT_CONFIG_*` 環境變數注入 `http.<url>.extraheader`,使 base64 憑證**不出現在 argv**
* (避免程序清單/例外回顯洩漏);推送目標 URL 亦不含帳密。
* 推送失敗時**不重拋原始例外**(其 message 會含命令列與遠端 URL),改拋固定訊息。
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} remoteUrl - 不含帳密的遠端 URL(形如 `https://host/owner/repo.git`)。
* @param {string} secret - 具 push 權限的 tokenPAT(作為 Basic 認證的密碼)。
* @param {string} refspec - push 的 refspec(形如 `HEAD:refs/heads/<branch>`)。
* @returns {void} 成功即返回;失敗拋出不含 URL/argv/token 的固定錯誤。
* @throws {Error} 推送失敗時拋出固定訊息(已隱藏遠端 URL 與認證資訊)。
* @remarks 本函式未匯出,僅供 {@link commitAndPushFindings} 使用。
*/
function pushWithCredential(cwd, remoteUrl, secret, refspec) {
const basic = Buffer.from(`ai-review-bot:${secret}`).toString('base64');
try {
execFileSync('git', ['push', remoteUrl, refspec], {
cwd,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: `http.${remoteUrl}.extraheader`,
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
},
});
} catch {
throw new Error('推送審查結果 commit 失敗(已隱藏遠端 URL 與認證資訊)。');
}
}
module.exports = {
latestCommitSubject,
resolveMergeBase,
changedFiles,
fileDiff,
fileLastUpdatedIso,
commitAndPushFindings,
};