Compare commits

...

26 Commits

Author SHA1 Message Date
admin 073659fab2 Merge pull request 'docs: update TODO acceptance status' (#95) from 整理程式碼 into develop
Reviewed-on: #95
2026-05-13 06:30:33 +00:00
AI Review Bot cf0040603b chore: update ai-review findings [skip ci] 2026-05-13 06:28:43 +00:00
jiantw83 5e623a3f2e docs: exclude current review findings 2026-05-13 06:27:43 +00:00
jiantw83 0c9748049c Revert "test: cover review edge cases and repair paths"
This reverts commit 61942eeebbba95c81431896c7fd8f43ff0e7c0d5.
2026-05-13 06:27:43 +00:00
jiantw83 3f3ead0f08 test: cover review edge cases and repair paths 2026-05-13 06:27:43 +00:00
AI Review Bot 8f413439b3 chore: update ai-review findings [skip ci] 2026-05-13 06:15:28 +00:00
jiantw83 480a0693f7 docs: update TODO acceptance status 2026-05-13 06:12:23 +00:00
jiantw83 154f486c43 Merge pull request '整理程式碼' (#93) from 整理程式碼 into develop
Reviewed-on: #93
2026-05-13 02:42:39 +00:00
AI Review Bot 79506eb905 chore: update ai-review findings [skip ci] 2026-05-13 02:24:36 +00:00
jiantw83 8872e7366a refactor: add performance improvement suggestion for filterDiff regex in exclusions.json 2026-05-13 02:23:30 +00:00
AI Review Bot 7616dd1816 chore: update ai-review findings [skip ci] 2026-05-13 02:18:00 +00:00
jiantw83 9bef365a32 refactor: enhance suggestions in exclusions.json for improved security and efficiency 2026-05-13 02:16:13 +00:00
AI Review Bot 21b3df6d79 chore: update ai-review findings [skip ci] 2026-05-13 01:47:56 +00:00
jiantw83 cc6345c32e refactor: update commitAndPush function to accept repoDir parameter and adjust related tests 2026-05-13 01:46:27 +00:00
AI Review Bot c758c99a28 chore: update ai-review findings [skip ci] 2026-05-13 01:42:23 +00:00
admin 505cf6d30d Merge pull request 'refactor: optimize AI payload by reducing token usage and streamline findings structure' (#89) from feat/壓縮AI內容 into 整理程式碼
Reviewed-on: #89
2026-05-13 01:41:12 +00:00
AI Review Bot c3e57ff442 chore: update ai-review findings [skip ci] 2026-05-13 01:40:58 +00:00
jiantw83 5876154dbb refactor: optimize AI payload by reducing token usage and streamline findings structure 2026-05-13 01:39:13 +00:00
AI Review Bot 0e0cd252b0 chore: update ai-review findings [skip ci] 2026-05-13 01:33:15 +00:00
jiantw83 fcc8d59f7a refactor: add suggestion for TODO.md clarification and enhance filterDiff test documentation 2026-05-13 01:31:25 +00:00
AI Review Bot a92b6440ff chore: update ai-review findings [skip ci] 2026-05-13 01:29:11 +00:00
jiantw83 8d8ace636e refactor: add new suggestion for filterDiff unit tests and update getPRDiff documentation for clarity 2026-05-13 01:27:33 +00:00
AI Review Bot fdeceee52f chore: update ai-review findings [skip ci] 2026-05-13 01:21:17 +00:00
jiantw83 fade942267 refactor: add new suggestion for comments.js and enhance filterDiff tests for better coverage 2026-05-13 01:18:21 +00:00
jiantw83 4834396652 refactor: streamline JSON file reading logic and improve error handling in findings.js and git.js 2026-05-13 01:16:34 +00:00
AI Review Bot 0108a05886 chore: update ai-review findings [skip ci] 2026-05-13 01:14:09 +00:00
10 changed files with 179 additions and 130 deletions
+67
View File
@@ -208,5 +208,72 @@
"role": "Zara",
"location": "app/main.js",
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
},
{
"role": "Leo",
"location": "app/comments.js",
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
},
{
"role": "Maya",
"location": "app/gitea.js",
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
},
{
"role": "Leo",
"location": "TODO.md",
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
},
{
"role": "Rex",
"location": "app/gitea.js",
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
},
{
"role": "Zara",
"location": "app/git.js",
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
},
{
"role": "Aria",
"location": "app/main.js",
"suggestion": "在 main.js 中,表達式 repoDir。"
},
{
"role": "Zara",
"location": "app/gitea.js:L20-L21",
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
},
{
"location": "TODO.md",
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
},
{
"location": "TODO.md",
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
},
{
"location": "app/findings.js",
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
},
{
"location": "app/findings.js",
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
},
{
"location": "app/comments.js",
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
},
{
"location": "app/main.js",
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
},
{
"location": "app/git.js",
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
},
{
"location": "app/main.js",
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
}
]
+1 -37
View File
@@ -1,37 +1 @@
[
{
"level": "warning",
"role": "Aria",
"location": "app/main.js:60",
"suggestion": "已移除的註解 `// 載入舊 findings,用於 AI 誤報過濾參考` 提供了該程式碼區塊的上下文資訊。建議保留此類註解或以 JSDoc 形式補充,以提升程式碼可讀性與維護性。",
"is_new": true
},
{
"level": "warning",
"role": "Aria",
"location": "app/main.js:64",
"suggestion": "已移除的註解 `// Clone repo 以讀取舊 findings 與排除清單` 說明了呼叫 `cloneRepo` 的目的。建議保留此類註解或以 JSDoc 形式補充,以提升程式碼可讀性與維護性。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "app/gitea.js:14",
"suggestion": "更新 `filterDiff` 的測試。過濾邏輯從正則表達式匹配改為 `startsWith`,這是一個功能性變更。需要新增或修改測試案例,以確保新的 `startsWith` 邏輯能正確過濾或保留 diff 區塊,特別是針對邊界條件和不同前綴的匹配情況。",
"is_new": true
},
{
"level": "info",
"role": "Rex",
"location": "action.yaml",
"suggestion": "此 Action 需要 `contents: write`、`pull-requests: write` 和 `issues: write` 權限。這些權限對於 Action 的正常運作是必要的(例如寫入 findings.json、發布評論),但屬於較廣泛的權限。建議在文件或使用說明中明確指出這些權限的需求及其潛在影響,確保使用者了解並接受。",
"is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "app/main.js:16",
"suggestion": "在 `main` 函式中,移除了多個高層次的註解,例如 `// 偵測 LLM`、`// 載入角色` 等。雖然這些註解描述了接下來的程式碼區塊,但對於理解整個 pipeline 的執行流程和各步驟的目標,它們提供了有用的指引。建議恢復這些高層次註解,以提升程式碼的整體可讀性和維護性,特別是對於新加入的開發者。",
"is_new": true
}
]
[]
+1
View File
@@ -24,6 +24,7 @@
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
7. 讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼
8. 階段五完成後驗證 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
# 使用說明
+18 -10
View File
@@ -3,49 +3,57 @@
## 階段一:基本流程串接
- 目標:確保 action 可以被觸發,pipeline 各步驟依序執行,log 出每個主要階段的進入與完成。
- 驗收:log 中能看到每個階段(如「Step1: pipeline start」、「Step2: findings merge」等)明確訊息,且流程能走完(即使還沒產生 findings)。
- 驗收
- 驗收`code-review` job 的 log 已完整出現 `Step1``Step8`,並以 `Pipeline 完成` 結束。
## 階段二:Git Diff 排除 .gitea/ 資料夾
- 目標:讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼。
- 驗收:PR 中有 `.gitea/` 路徑的變更時,diff 內容不包含該路徑的區塊,AI 分析結果不含 `.gitea/` 相關問題。
- 驗收
- 驗收`app/gitea.js` 已在取得 diff 時過濾 `.gitea/` 區塊,且相關單元測試已覆蓋。
## 階段三:Findings 產生與合併
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
- 驗收
- 驗收:log 已顯示 5 個角色皆有分析結果,並出現 `Step3 merged findings total=13`
## 階段四:AI 去重與角色確認
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
- 驗收
- 驗收log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
## 階段五:AI 排除問題過濾
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾,並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
- 未驗收
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆``排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`
## 階段六:findings 寫入與 comment 發布
- 目標:`.gitea/ai-review/findings.json` 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
- 驗收:log 中能看到 `.gitea/ai-review/findings.json` 寫入、comment sync 的詳細訊息與順序。
- 未驗收
- 部分驗收:`findings.json` 已成功寫入,也有依序執行舊問題、非嚴重、嚴重 comment 流程;但本次因結果為 0 筆,沒有實際 comment 內容可完整驗證順序。
- 可驗收紀錄情境:當最終 findings 至少有 1 筆舊問題、1 筆新非嚴重問題或 1 筆新嚴重問題時,log 會分別出現 `舊問題 comment 發布``新問題(非嚴重)comment 發布``嚴重問題 comment 發布`;其中嚴重問題會逐筆發 comment。
## 階段七:階段六後驗證 JSON 格式
- 目標:階段六完成後驗證 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1。
- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有「嘗試修正」訊息與備份路徑,修正失敗時 workflow 狀態為失敗。
- 驗收
- 驗收log 已明確顯示 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`
## 階段八:記憶區 commit/push 與錯誤處理
- 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
- 驗收
- 驗收log 已出現 `persisted findings commit=79506eb push=整理程式碼`,代表 commit/push 成功。
## 階段九:阻擋嚴重問題 PR(第 8 點)
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
- 未驗收
- 部分驗收:這次 log 顯示 `✅ 無嚴重問題`,因此只驗到正常放行路徑;`exit 1` 的阻擋分支仍需另一次含 critical 的 PR log 驗證。
- 可驗收紀錄情境:只要 `Step8` 出現 `發現 X 個嚴重問題,workflow 結束(exit 1`,且 job 以失敗結束,就能驗收這一項;如果該次 PR 的 `filtered` 清單含 `critical`,就應該會看到這段 log。
## 階段十:API Key 輪替
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
- 驗收
- 驗收`review.yaml` 已以逗號串接多把 Gemini key,且 `app/llm.js` 與單元測試已覆蓋輪替與失敗退出行為。
## 階段十一:壓縮 AI 傳入內容減少 token 用量
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestionAI 回傳後補回原始完整欄位(含 is_new)。
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
- 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。
+32 -38
View File
@@ -18,25 +18,31 @@ export async function analyzeWithRole(role, diff) {
}
/**
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列
*/
export function loadOldFindings(workspace) {
const fullPath = path.join(workspace, FINDINGS_PATH);
function readJSONArray(fullPath, label) {
if (!fs.existsSync(fullPath)) {
console.log(' 舊 findings 檔案不存在,視為空');
console.log(` ${label}檔案不存在,視為空`);
return [];
}
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
const old = (Array.isArray(data) ? data : []).map(f => ({ ...f, is_new: false }));
console.log(` 讀取舊 findings: ${old.length}`);
return old;
return Array.isArray(data) ? data : [];
} catch (e) {
console.log(` ⚠️ 讀取舊 findings 失敗: ${e.message},視為空`);
console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`);
return [];
}
}
/**
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH
*/
export function loadOldFindings(workspace) {
const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false }));
console.log(` 讀取舊 findings: ${old.length}`);
return old;
}
/**
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
*/
@@ -70,22 +76,26 @@ function fallback(label, findings, e) {
return findings;
}
/** 只保留 AI 需要的欄位,減少 token 用量 */
function toAIPayload(findings) {
return findings.map(({ level, role, location, suggestion }) => ({ level, role, location, suggestion }));
}
/**
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
*/
export async function deduplicateWithAI(findings) {
if (findings.length === 0) return findings;
const systemPrompt = `你是一位程式碼審查問題去重專家。
給你一份問題清單(JSON 陣列),請移除語意重複的問題(即使描述文字不同,但指的是同一個問題)。
保留等級較高的版本,優先保留 critical > warning > info。
只回傳去重後的 JSON 陣列,不要有其他文字。`;
const systemPrompt = `移除語意重複的程式碼審查問題(JSON 陣列)。保留等級較高者(critical > warning > info)。只回傳去重後的 JSON 陣列。`;
try {
const result = await chatJSON(systemPrompt, `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`);
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) {
console.log(` AI 去重: ${findings.length} -> ${result.length}`);
return result;
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
}
throw new Error('AI 回傳空陣列');
} catch (e) {
@@ -97,20 +107,9 @@ export async function deduplicateWithAI(findings) {
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH
*/
export function loadExclusions(workspace) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
if (!fs.existsSync(fullPath)) {
console.log(' 排除問題檔案不存在,跳過過濾');
return [];
}
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
const exclusions = Array.isArray(data) ? data : [];
console.log(` 讀取排除問題: ${exclusions.length}`);
return exclusions;
} catch (e) {
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},跳過過濾`);
return [];
}
const exclusions = readJSONArray(path.join(workspace, EXCLUSIONS_PATH), '排除問題');
console.log(` 讀取排除問題: ${exclusions.length}`);
return exclusions;
}
/**
@@ -136,22 +135,17 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) {
if (findings.length === 0) return findings;
const exclusionHint = exclusions.length > 0
? `\n\n以下是已知誤報或不需處理的問題清單(供參考,相同檔案路徑且語意相近的問題應一併排除):\n${JSON.stringify(exclusions, null, 2)}`
? `\n已知誤報(相同路徑且語意相近一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}`
: '';
const systemPrompt = `你是一位資深程式碼審查專家,負責判斷審查問題是否為誤報或不需處理。
給你一份問題清單(JSON 陣列),每筆包含 level、role、location、suggestion。
請移除以下類型的問題:
1. 誤報:問題描述與實際程式碼不符(例如:程式碼已正確使用環境變數或 secrets,卻被標記為硬編碼敏感資料)
2. 不適用:問題在此專案情境下不需處理(例如:CI/CD action 本來就需要透過環境變數傳遞 token)
3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似)
只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`;
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
try {
const result = await chatJSON(systemPrompt, `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`);
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) {
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length}`);
return result;
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
}
throw new Error('AI 回傳空陣列或非陣列');
} catch (e) {
+3 -5
View File
@@ -3,6 +3,8 @@ import fs from 'fs';
import path from 'path';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
function makeRunner(spawn) {
return function run(args, cwd, env) {
const opts = { cwd, encoding: 'utf8' };
@@ -30,7 +32,6 @@ function withAskpass(workspace, fn) {
*/
export function cloneRepo(workspace, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
const repoDir = path.join(workspace, 'repo');
return withAskpass(workspace, credEnv => {
@@ -46,13 +47,10 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
});
}
export async function commitAndPush(workspace, _spawnSync = spawnSync) {
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
try {
const repoDir = cloneRepo(workspace, _spawnSync);
await withAskpass(workspace, async credEnv => {
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['config', 'user.name', 'AI Review Bot'], repoDir);
+6 -7
View File
@@ -38,7 +38,6 @@ describe('commitAndPush', () => {
before(() => { workspace = makeTmpWorkspace(); });
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
beforeEach(() => {
// Remove leftover askpass scripts between tests
for (const f of fs.readdirSync(workspace)) {
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
}
@@ -46,7 +45,7 @@ describe('commitAndPush', () => {
it('does not embed token in any git command argument', async () => {
const spawn = makeSpawn();
await commitAndPush(workspace, spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
for (const { args } of spawn.calls) {
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
@@ -55,7 +54,7 @@ describe('commitAndPush', () => {
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
const spawn = makeSpawn();
await commitAndPush(workspace, spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
const networkOps = ['fetch', 'push', 'clone'];
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
@@ -67,28 +66,28 @@ describe('commitAndPush', () => {
});
it('cleans up askpass script after successful run', async () => {
await commitAndPush(workspace, makeSpawn());
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn());
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
});
it('cleans up askpass script even when git fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await commitAndPush(workspace, failSpawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
});
it('skips commit when status shows no changes', async () => {
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
await commitAndPush(workspace, spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
});
it('does not throw when git command fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await assert.doesNotReject(() => commitAndPush(workspace, failSpawn));
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn));
});
});
+4 -1
View File
@@ -6,9 +6,12 @@ const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized:
const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' });
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
/**
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
*/
export async function getPRDiff() {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
return resp.data;
return filterDiff(resp.data, ['.gitea/']);
}
/**
+30 -5
View File
@@ -2,13 +2,10 @@ import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import axios from 'axios';
// gitea.js reads env vars at module load time (ESM cache), so we test
// the actual values baked in at import time and verify behavior via axios mocks.
afterEach(() => mock.restoreAll());
describe('gitea', async () => {
const { getPRDiff, postComment } = await import('./gitea.js');
const { getPRDiff, filterDiff, postComment } = await import('./gitea.js');
it('getPRDiff calls Gitea diff API with Authorization header', async () => {
let capturedUrl, capturedOpts;
@@ -48,7 +45,6 @@ describe('gitea', async () => {
return { data: '' };
});
await getPRDiff();
// httpsAgent is undefined when GITEA_SKIP_TLS_VERIFY !== 'true'
assert.equal(capturedOpts.httpsAgent, undefined);
});
@@ -62,3 +58,32 @@ describe('gitea', async () => {
await assert.rejects(() => postComment('test'), /api error/);
});
});
describe('filterDiff', async () => {
const { filterDiff } = await import('./gitea.js');
const block = (file) => `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-old\n+new\n`;
it('filters out .gitea/ blocks', () => {
const diff = block('.gitea/workflows/review.yaml') + block('src/index.js');
const result = filterDiff(diff, ['.gitea/']);
assert.ok(!result.includes('.gitea/'));
assert.ok(result.includes('src/index.js'));
});
it('does not filter non-.gitea/ blocks', () => {
const diff = block('src/index.js') + block('README.md');
const result = filterDiff(diff, ['.gitea/']);
assert.equal(result, diff);
});
it('returns empty string when all blocks are excluded', () => {
const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json');
const result = filterDiff(diff, ['.gitea/']);
assert.equal(result, '');
});
it('returns empty string for empty diff', () => {
assert.equal(filterDiff('', ['.gitea/']), '');
});
});
+17 -27
View File
@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { loadRoles, getRoleIntro } from './roles.js';
import { getPRDiff, filterDiff, postComment } from './gitea.js';
import { getPRDiff, postComment } from './gitea.js';
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush } from './git.js';
@@ -47,18 +47,8 @@ async function main() {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
}
// Step2: 排除 .gitea/ 資料夾內的所有檔案
console.log('\n🗂️ Step2: Git Diff 過濾');
diff = filterDiff(diff, ['.gitea/']);
console.log(` 排除 .gitea/ 後 diff 長度: ${diff.length} 字元`);
if (!diff.trim()) {
console.log(' ⚠️ 過濾後 diff 為空,無需審查');
process.exit(0);
}
// Step3: 各角色分析 diff 產生新 findings
console.log('\n📊 Step3: Findings 產生');
// Step2: 各角色分析 diff 產生新 findings
console.log('\n📊 Step2: Findings 產生');
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
const newFindings = [];
for (let i = 0; i < results.length; i++) {
@@ -68,10 +58,10 @@ async function main() {
console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
}
}
console.log(` Step3 完成: 新 findings 總計 ${newFindings.length}`);
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length}`);
// Step4: 讀取舊 findings,合併去重(含 AI 語意去重)
console.log('\n🔀 Step4: Findings 合併');
console.log('\n🔀 Step3: Findings 合併');
// Clone repo 以讀取舊 findings 與排除清單
let repoDir;
try {
@@ -81,35 +71,35 @@ async function main() {
}
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
const mergedFindings = mergeFindings(oldFindings, newFindings);
console.log(` Step4 merged findings total=${mergedFindings.length}`);
console.log(` Step3 merged findings total=${mergedFindings.length}`);
console.log('\n🤖 Step4b: AI 語意去重');
console.log('\n🤖 Step3b: AI 語意去重');
const deduped = await deduplicateWithAI(mergedFindings);
const sorted = sortByLevel(deduped);
console.log(` Step4b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
console.log(` Step3b dedup findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
console.log('\n🚫 Step5: AI 排除問題過濾');
console.log('\n🚫 Step4: AI 排除問題過濾');
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
const exclusions = loadExclusions(repoDir || WORKSPACE);
const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
console.log(` Step5 完成: findings total=${filtered.length}`);
console.log(` Step4 完成: findings total=${filtered.length}`);
// Step6: 寫入 findings.json,依序發布 comment
console.log('\n📝 Step6: Findings 寫入與 Comment 發布');
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
saveFindings(WORKSPACE, filtered);
try {
await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered);
await postNewCriticalComments(filtered);
console.log(' Step6 完成');
console.log(' Step5 完成');
} catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
}
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
console.log('\n🔎 Step7: JSON 格式驗證');
console.log('\n🔎 Step6: JSON 格式驗證');
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(repoDir || WORKSPACE, relPath);
if (!fs.existsSync(fullPath)) {
@@ -133,12 +123,12 @@ async function main() {
}
}
// Step8: commit/push findings.json 到來源分支
console.log('\n💾 Step8: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE);
// Step7: commit/push findings.json 到來源分支
console.log('\n💾 Step7: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE, repoDir);
// Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step9: 嚴重問題檢查');
console.log('\n🚦 Step8: 嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length;
if (criticalCount > 0) {
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);