diff --git a/app/git.js b/app/git.js index 5209de8..75041ef 100644 --- a/app/git.js +++ b/app/git.js @@ -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); diff --git a/app/git.test.js b/app/git.test.js index 0e7e85b..bbf92e3 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -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)); }); }); diff --git a/app/gitea.js b/app/gitea.js index 7c8fdcb..79f1f76 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -7,12 +7,11 @@ const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; /** - * 取得 PR 的原始 Git Diff 內容。 - * 注意:回傳值未經路徑過濾,呼叫端須使用 filterDiff 排除敏感路徑(如 .gitea/)後再傳給 AI。 + * 取得 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/']); } /** diff --git a/app/main.js b/app/main.js index 02f5d15..c51e4ee 100644 --- a/app/main.js +++ b/app/main.js @@ -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)`);