refactor: update commitAndPush function to accept repoDir parameter and adjust related tests

This commit is contained in:
2026-05-13 01:46:27 +00:00
parent c758c99a28
commit cc6345c32e
4 changed files with 26 additions and 40 deletions
+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); const run = makeRunner(_spawnSync);
try { try {
const repoDir = cloneRepo(workspace, _spawnSync);
await withAskpass(workspace, async credEnv => { await withAskpass(workspace, async credEnv => {
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir); run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['config', 'user.name', 'AI Review Bot'], repoDir); run(['config', 'user.name', 'AI Review Bot'], repoDir);
+6 -7
View File
@@ -38,7 +38,6 @@ describe('commitAndPush', () => {
before(() => { workspace = makeTmpWorkspace(); }); before(() => { workspace = makeTmpWorkspace(); });
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); }); after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
beforeEach(() => { beforeEach(() => {
// Remove leftover askpass scripts between tests
for (const f of fs.readdirSync(workspace)) { for (const f of fs.readdirSync(workspace)) {
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f)); 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 () => { it('does not embed token in any git command argument', async () => {
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
for (const { args } of spawn.calls) { for (const { args } of spawn.calls) {
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`); 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 () => { it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
const spawn = makeSpawn(); const spawn = makeSpawn();
await commitAndPush(workspace, spawn); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
const networkOps = ['fetch', 'push', 'clone']; const networkOps = ['fetch', 'push', 'clone'];
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0])); 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 () => { 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')); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up'); assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
}); });
it('cleans up askpass script even when git fails', async () => { it('cleans up askpass script even when git fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); 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')); 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'); assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
}); });
it('skips commit when status shows no changes', async () => { it('skips commit when status shows no changes', async () => {
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) }); 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'); const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
assert.equal(commitCalled, false, 'commit should not run when there are no changes'); assert.equal(commitCalled, false, 'commit should not run when there are no changes');
}); });
it('does not throw when git command fails', async () => { it('does not throw when git command fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); 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));
}); });
}); });
+2 -3
View File
@@ -7,12 +7,11 @@ const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type':
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
/** /**
* 取得 PR 的原始 Git Diff 內容。 * 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾
* 注意:回傳值未經路徑過濾,呼叫端須使用 filterDiff 排除敏感路徑(如 .gitea/)後再傳給 AI。
*/ */
export async function getPRDiff() { export async function getPRDiff() {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent }); 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 path from 'path';
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; 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 { 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 { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush } from './git.js'; import { cloneRepo, commitAndPush } from './git.js';
@@ -47,18 +47,8 @@ async function main() {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
} }
// Step2: 排除 .gitea/ 資料夾內的所有檔案 // Step2: 各角色分析 diff 產生新 findings
console.log('\n🗂️ Step2: Git Diff 過濾'); console.log('\n📊 Step2: Findings 產生');
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 產生');
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
const newFindings = []; const newFindings = [];
for (let i = 0; i < results.length; i++) { 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(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
} }
} }
console.log(` Step3 完成: 新 findings 總計 ${newFindings.length}`); console.log(` Step2 完成: 新 findings 總計 ${newFindings.length}`);
// Step4: 讀取舊 findings,合併去重(含 AI 語意去重) // Step4: 讀取舊 findings,合併去重(含 AI 語意去重)
console.log('\n🔀 Step4: Findings 合併'); console.log('\n🔀 Step3: Findings 合併');
// Clone repo 以讀取舊 findings 與排除清單 // Clone repo 以讀取舊 findings 與排除清單
let repoDir; let repoDir;
try { try {
@@ -81,35 +71,35 @@ async function main() {
} }
const oldFindings = loadOldFindings(repoDir || WORKSPACE); const oldFindings = loadOldFindings(repoDir || WORKSPACE);
const mergedFindings = mergeFindings(oldFindings, newFindings); 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 deduped = await deduplicateWithAI(mergedFindings);
const sorted = sortByLevel(deduped); 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 判斷誤報 // Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
console.log('\n🚫 Step5: AI 排除問題過濾'); console.log('\n🚫 Step4: AI 排除問題過濾');
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考 // 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
const exclusions = loadExclusions(repoDir || WORKSPACE); const exclusions = loadExclusions(repoDir || WORKSPACE);
const ruleFiltered = applyExclusions(sorted, exclusions); const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, 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 // Step6: 寫入 findings.json,依序發布 comment
console.log('\n📝 Step6: Findings 寫入與 Comment 發布'); console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
saveFindings(WORKSPACE, filtered); saveFindings(WORKSPACE, filtered);
try { try {
await postOldFindingsComment(filtered); await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered); await postNewNonCriticalComment(filtered);
await postNewCriticalComments(filtered); await postNewCriticalComments(filtered);
console.log(' Step6 完成'); console.log(' Step5 完成');
} catch (e) { } catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
} }
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON // Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
console.log('\n🔎 Step7: JSON 格式驗證'); console.log('\n🔎 Step6: JSON 格式驗證');
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(repoDir || WORKSPACE, relPath); const fullPath = path.join(repoDir || WORKSPACE, relPath);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
@@ -133,12 +123,12 @@ async function main() {
} }
} }
// Step8: commit/push findings.json 到來源分支 // Step7: commit/push findings.json 到來源分支
console.log('\n💾 Step8: 記憶區 Commit/Push'); console.log('\n💾 Step7: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE); await commitAndPush(WORKSPACE, repoDir);
// Step9: 有 critical 問題則 exit 1 // Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step9: 嚴重問題檢查'); console.log('\n🚦 Step8: 嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length; const criticalCount = filtered.filter(f => f.level === 'critical').length;
if (criticalCount > 0) { if (criticalCount > 0) {
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`); console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);