Compare commits

..

10 Commits

9 changed files with 66 additions and 61 deletions
+10
View File
@@ -213,5 +213,15 @@
"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 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
}
]
+7 -7
View File
@@ -1,16 +1,16 @@
[
{
"level": "critical",
"role": "Leo",
"location": "app/comments.js:66",
"suggestion": "`buildTable` 函式在此檔案中被呼叫,但未見其定義或匯入。這將導致執行時錯誤。請確保 `buildTable` 函式已被正確定義或從其他模組匯入,以確保程式碼的正確執行。",
"level": "warning",
"role": "Rex",
"location": "app/gitea.js:10",
"suggestion": "`getPRDiff` 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 `main.js` 中已立即呼叫 `filterDiff` 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端。這增加了未來開發者在其他地方呼叫 `getPRDiff` 時,可能忘記或錯誤地應用過濾,導致 `.gitea/` 等敏感路徑的內容(可能包含工作流程設定或機密資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議考慮將過濾邏輯保留在 `getPRDiff` 內部,或提供一個明確的 `getFilteredPRDiff` 函數,以降低誤用的風險。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "app/gitea.js:11, app/main.js:42-45",
"suggestion": "`filterDiff` 函數的邏輯已從正規表達式比對改為 `startsWith`,並將其呼叫 `getPRDiff` 移至 `main.js`。雖然 `startsWith` 可能更高效精確,但這是一個行為變更與職責重分配。請確保為 `filterDiff` 函數撰寫足夠的單元測試,以驗證:\n1. 正確過濾 `.gitea/` 路徑下的檔案。\n2. 不會錯誤過濾非 `.gitea/` 路徑下的檔案。\n3. 處理空 diff 內容。\n4. 處理僅包含 `.gitea/` 檔案的 diff 內容(應返回空字串)。",
"role": "Zara",
"location": "app/git.js, app/main.js",
"suggestion": "在 `app/main.js` 中,`commitAndPush` 函數內部會再次呼叫 `cloneRepo`。然而,`main.js` 在此之前已經呼叫過 `cloneRepo` 以取得 `repoDir`。這導致了重複的 `git fetch` 和 `git checkout` 操作,即使 `cloneRepo` 內部有檢查機制,仍會造成不必要的資源消耗和時間延遲。建議修改 `commitAndPush` 函數,使其接收已存在的 `repoDir` 作為參數,避免重複執行 `cloneRepo`。",
"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 用量
# 使用說明
+5
View File
@@ -49,3 +49,8 @@
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
- 未驗收
## 階段十一:壓縮 AI 傳入內容減少 token 用量
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestionAI 回傳後補回原始完整欄位(含 is_new)。
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
- 未驗收
+15 -16
View File
@@ -76,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) {
@@ -131,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) {
+1 -3
View File
@@ -47,12 +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);
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/']);
}
/**
+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`);