'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/`,再以 `git merge-base origin/ 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/,再嘗試 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 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 HEAD -- `,回傳原始 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 -- `,回傳 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 ` 站上 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/`;不含 '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..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:@host/...` 的 HTTP Basic(git 會把 * URL 帳密轉成相同的 `Authorization: Basic` 標頭送出),但改以 git 的 * `GIT_CONFIG_*` 環境變數注入 `http..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/`)。 * @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, };