- 移除 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>
322 lines
16 KiB
JavaScript
322 lines
16 KiB
JavaScript
'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 指令的原始 stdout(UTF-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 SHA(40 碼十六進位字串)。
|
||
* @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 帶的自動 token(gitea.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 Basic(git 會把
|
||
* 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 權限的 token/PAT(作為 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,
|
||
};
|