feat: 導入 AI 程式碼審查 action 並修正進入點與參數接線 #1

Merged
admin merged 30 commits from ai-review-resolve/develop-20260702-160700 into develop 2026-07-03 10:04:33 +00:00
Showing only changes of commit 2f294845c8 - Show all commits
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
// main() 是整個 action 的流程總管(Step1~Step11),本測試用 node:test 的 module mock
// 把所有相依模組換成可控 stub,逐一驗證關鍵分支的 exit code 與退出時機。
// 需要 `--experimental-test-module-mocks`(見 package.json test script)。
const U = (rel) => new URL(rel, import.meta.url).href;
// 每個相依模組的預設 stub(快樂路徑:無 critical、diff 非空、角色分析成功)。
// 各測試以 overrides 覆寫要驗證的分支。
function baseStubs() {
return {
config: {
GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop',
FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json',
getLLMConfig: () => ({ provider: 'codex', apiKeys: ['codex'], baseURL: null, model: 'gpt-5.5', command: 'codex' }),
},
roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' },
gitea: {
getPRDiff: async () => 'diff --git a/x b/x\n+code',
postComment: async () => ({ id: 1 }),
getCommitMessageBySha: async () => 'normal commit',
getBotReviewOutcome: () => null,
shouldSkipBotCommit: async () => false,
},
findings: {
analyzeWithRole: async () => [],
loadOldFindings: () => [],
mergeFindings: (a, b) => [...a, ...b],
sortByLevel: (a) => a,
deduplicateWithAI: async (a) => a,
loadExclusions: () => [],
applyExclusions: (a) => a,
filterFalsePositivesWithAI: async (a) => a,
appendExclusions: () => null,
resolveMissingLineNumbers: async (a) => a,
},
resolve: {
reconcileConversations: async () => ({ resolvedFindings: [], excludedFindings: [], carriedFindings: [], resolvedCount: 0, falsePositiveCount: 0, openCount: 0, closedCount: 0 }),
dropResolvedFindings: (a) => a,
addCarriedFindings: (a) => a,
},
comments: { saveFindings: () => {}, postFindingsReview: async () => {}, formatFindingsStatsLine: () => '' },
usage: { getRunUsage: () => ({}), getRateLimit: () => ({}), fetchAccountQuota: async () => ({}), formatUsageStats: () => '', formatUsageStatsLine: () => '' },
git: { cloneRepo: () => '/repo', commitAndPush: async () => {}, getRepoState: () => ({ branch: 'feat', shortSha: 'abc' }) },
json: { validateJSONArrayFile: async () => ({ exists: true }), ensureJSONArrayFileExists: () => {} },
preflight: { runPreflight: async () => true },
llm: { mapWithConcurrency: async (items, _l, fn) => Promise.all(items.map(fn)), LLM_CONCURRENCY: 0 },
log: { section() {}, step() {}, line() {}, input() {}, output() {}, result() {}, warn() {}, error() {} },
};
}
// 套用 mock 並載入一份全新的 main(以 query 破快取),回傳 main()。
let importSeq = 0;
async function loadMain(overrides = {}) {
const s = baseStubs();
for (const k of Object.keys(overrides)) s[k] = { ...s[k], ...overrides[k] };
mock.module(U('../config.js'), { namedExports: s.config });
mock.module(U('../roles.js'), { namedExports: s.roles });
mock.module(U('../gitea.js'), { namedExports: s.gitea });
mock.module(U('../findings.js'), { namedExports: s.findings });
mock.module(U('../resolve.js'), { namedExports: s.resolve });
mock.module(U('../comments.js'), { namedExports: s.comments });
mock.module(U('../usage.js'), { namedExports: s.usage });
mock.module(U('../git.js'), { namedExports: s.git });
mock.module(U('../json.js'), { namedExports: s.json });
mock.module(U('../preflight.js'), { namedExports: s.preflight });
mock.module(U('../llm.js'), { namedExports: s.llm });
mock.module(U('../log.js'), { namedExports: s.log });
const mod = await import(`../main.js?seq=${importSeq++}`);
return mod.main;
}
// 執行 main(),攔截 process.exit 並回傳 exit code(正常結束回 0)。
async function runMain(overrides) {
const main = await loadMain(overrides);
mock.method(process, 'exit', (code) => { throw Object.assign(new Error('__exit__'), { __code: code ?? 0 }); });
try {
await main();
return 0;
} catch (e) {
if (e && e.__code !== undefined) return e.__code;
throw e;
}
}
describe('main pipeline', () => {
beforeEach(() => { process.env.PR_HEAD_SHA = 'deadbeef'; });
afterEach(() => { mock.restoreAll(); delete process.env.PR_HEAD_SHA; });
it('前置驗證失敗 → exit 1', async () => {
assert.equal(await runMain({ preflight: { runPreflight: async () => false } }), 1);
});
it('偵測到 [ai-review-bot][failure] → exit 1', async () => {
assert.equal(await runMain({
gitea: { getCommitMessageBySha: async () => 'chore: update [ai-review-bot][failure]', getBotReviewOutcome: () => 'failure' },
}), 1);
});
it('本次為 bot 自動提交 → exit 0(跳過審查)', async () => {
assert.equal(await runMain({ gitea: { shouldSkipBotCommit: async () => true } }), 0);
});
it('未偵測到 LLM provider → exit 1', async () => {
assert.equal(await runMain({ config: { getLLMConfig: () => ({ provider: null, apiKeys: [], baseURL: null, model: null, command: null }) } }), 1);
});
it('diff 為空 → exit 0', async () => {
assert.equal(await runMain({ gitea: { getPRDiff: async () => ' ' } }), 0);
});
it('所有角色分析皆失敗 → exit 1', async () => {
assert.equal(await runMain({ findings: { analyzeWithRole: async () => { throw new Error('boom'); } } }), 1);
});
it('JSON 格式驗證失敗 → exit 1', async () => {
assert.equal(await runMain({ json: { validateJSONArrayFile: async () => { throw new Error('bad json'); } } }), 1);
});
it('產生 critical finding → exit 1', async () => {
const crit = [{ level: 'critical', role: 'Mage', location: 'a.js:1', suggestion: 'fix' }];
assert.equal(await runMain({ findings: { analyzeWithRole: async () => crit, filterFalsePositivesWithAI: async () => crit } }), 1);
});
it('無 critical → 正常走完(exit 0', async () => {
assert.equal(await runMain(), 0);
});
it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0', async () => {
assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0);
});
});