feat(ai-code-review): 新增 AI 多角色 code review action(攻防審查、findings 保存、建問題模式)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6a0573b7c0
commit
d08b97bd87
@@ -0,0 +1,219 @@
|
||||
'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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得目前 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。
|
||||
*
|
||||
* 先嘗試 `git fetch origin <baseRef>` 更新 base 分支資料(失敗時靜默忽略,
|
||||
* 因 fetch-depth: 0 的 checkout 通常已含 base 分支,可直接沿用本地資料),
|
||||
* 再以 `git merge-base origin/<baseRef> HEAD` 取得共同祖先。
|
||||
*
|
||||
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
|
||||
* @param {string} baseRef - PR 目標(base)分支名稱,例如 'master' 或 'develop';不含 'origin/' 前綴。
|
||||
* @returns {string} merge-base 的 commit SHA(40 碼十六進位字串)。
|
||||
* @throws {Error} 本地不存在 origin/<baseRef>、或兩者無共同祖先時,`git merge-base` 失敗並拋出(fetch 失敗不會拋出)。
|
||||
* @remarks
|
||||
* 使用情境:AI code review 以此結果作為 diff 比較基準——
|
||||
* 先 `resolveMergeBase(cwd, pr.base.ref)` 取得基準 SHA,
|
||||
* 再傳給 changedFiles / fileDiff 只審查 PR 實際引入的變更,
|
||||
* 避免把 base 分支後續演進誤算進 diff。
|
||||
*/
|
||||
function resolveMergeBase(cwd, baseRef) {
|
||||
try {
|
||||
git(cwd, 'fetch', 'origin', baseRef);
|
||||
} catch {
|
||||
// fetch-depth: 0 的 checkout 通常已含 base 分支,抓不到時直接沿用本地資料。
|
||||
}
|
||||
return gitTrim(cwd, 'merge-base', `origin/${baseRef}`, 'HEAD');
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 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 先走 origin;失敗(遠端未帶認證)時改用帶 token 的 URL 重試。
|
||||
*
|
||||
* @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;僅在 origin push 失敗時用於組出帶認證的重試 URL。
|
||||
* @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」或「無實際變更、略過」的不同日誌。
|
||||
*
|
||||
* 安全注意:push 重試時組出的 URL 內含 token(形如
|
||||
* `https://ai-review-bot:<token>@host/owner/repo.git`),
|
||||
* 絕對不得將此 URL 輸出到日誌、錯誤訊息或任何 action 輸出,以免洩漏 token;
|
||||
* 若需記錄重試行為,只能記載「改用帶認證 URL 重試」而不得包含 URL 本身。
|
||||
*/
|
||||
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,
|
||||
);
|
||||
try {
|
||||
git(cwd, 'push', 'origin', `HEAD:refs/heads/${headRef}`);
|
||||
} catch {
|
||||
// 遠端未帶認證(checkout 未保留 credentials)時,改用帶 token 的 URL 重試。
|
||||
// 注意:不得把這個 URL 輸出到日誌,避免洩漏 token。
|
||||
const url = new URL(`${serverUrl}/${repository}.git`);
|
||||
url.username = 'ai-review-bot';
|
||||
url.password = token;
|
||||
git(cwd, 'push', url.toString(), `HEAD:refs/heads/${headRef}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
latestCommitSubject,
|
||||
resolveMergeBase,
|
||||
changedFiles,
|
||||
fileDiff,
|
||||
fileLastUpdatedIso,
|
||||
commitAndPushFindings,
|
||||
};
|
||||
Reference in New Issue
Block a user