Compare commits

..

1 Commits

10 changed files with 218 additions and 243 deletions
-7
View File
@@ -1,7 +0,0 @@
[
{
"role": "Rex",
"location": "app/git.js",
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
}
]
+26 -40
View File
@@ -2,8 +2,8 @@
{ {
"level": "critical", "level": "critical",
"role": "Leo", "role": "Leo",
"location": "app/config.js:7", "location": "app/git.js:11",
"suggestion": "請確保 EXCLUSIONS_PATH 的值不包含敏感資訊,並使用環境變數來管理敏感資。", "suggestion": "GITEA_TOKEN 直接嵌入 URL 中,可能導致憑證洩漏。建議使用環境變數或安全的憑證管理方式來處理敏感資。",
"is_new": true "is_new": true
}, },
{ {
@@ -15,58 +15,44 @@
}, },
{ {
"level": "warning", "level": "warning",
"role": "Zara", "role": "Leo",
"location": "app/findings.js:40", "location": "app/git.js:25",
"suggestion": "在 applyExclusions 函數中,使用 filter 和 some 方法的組合可能會導致效能問題,特別是當 findings 和 exclusions 的數量很大時。考慮使用更有效的資料結構(如 HashSet)來加速查詢。", "suggestion": "在使用 fs.existsSync 檢查目錄是否存在時,應考慮使用非同步方法以避免阻塞事件循環。",
"is_new": true "is_new": true
}, },
{ {
"level": "warning", "level": "warning",
"role": "Rex", "role": "Leo",
"location": "app/findings.js:40", "location": "app/git.js:29",
"suggestion": "在讀取排除問題檔案時,建議加入對檔案內容的驗證,以防止不正確的格式導致潛在的錯誤或漏洞。", "suggestion": "在 git clone 時使用 --depth=1 可能會導致未來需要完整歷史紀錄時的性能問題,建議根據實際需求調整。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "app/git.js:11",
"suggestion": "在使用 fs.copyFileSync 時,未檢查目標檔案是否存在,可能會覆蓋重要資料。建議在複製之前檢查檔案是否存在。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "app/git.js:11",
"suggestion": "在 commitAndPush 函數中,對於 git 操作的錯誤處理不夠完善,應該添加更多的測試來驗證不同情況下的行為。",
"is_new": true "is_new": true
}, },
{ {
"level": "info", "level": "info",
"role": "Leo", "role": "Leo",
"location": "app/findings.js:1", "location": ".gitea/workflows/review.yaml:5",
"suggestion": "建議在檔案開頭添加檔案的功能描述,以提高可讀性。", "suggestion": "建議在 'branches-ignore' 前加上空行,以提高可讀性。",
"is_new": true "is_new": true
}, },
{ {
"level": "info", "level": "info",
"role": "Leo", "role": "Leo",
"location": "app/findings.js:40", "location": "app/git.js:45",
"suggestion": "建議為 loadExclusions 函式添加詳細的文件說明,以便未來的開發者能更快理解其功能。", "suggestion": "考慮使用 async/await 來處理 fs.copyFileSync,以提高可讀性和錯誤處理能力。",
"is_new": true
},
{
"level": "info",
"role": "Leo",
"location": "app/findings.js:93",
"suggestion": "建議為 deduplicateWithAI 函式添加詳細的文件說明,以便未來的開發者能更快理解其功能。",
"is_new": true
},
{
"level": "info",
"role": "Aria",
"location": "README.md:10",
"suggestion": "建議在每個步驟後添加簡短的描述,以提高可讀性和理解性。",
"is_new": true
},
{
"level": "info",
"role": "Aria",
"location": "app/config.js:7",
"suggestion": "建議在常數命名中使用全大寫字母和底線分隔,以提高可讀性。",
"is_new": true
},
{
"level": "info",
"role": "Maya",
"location": "app/main.js:50",
"suggestion": "建議在發佈 comment 失敗時,記錄具體的錯誤原因,以便後續調試。",
"is_new": true "is_new": true
} }
] ]
+115 -6
View File
@@ -7,12 +7,11 @@
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
3. 讀取所有未解決的舊問題(問題檔案存在於使用此 Action 的專案固定位置)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案 3. 讀取所有未解決的舊問題(問題檔案存在於使用此 Action 的專案固定位置)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案
4. 讀取排除問題檔案,用來過濾PR問題表格中不需要處理的問題 4. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
5. 從PR問題表格中取出所有問題,依照等級排序後 Comment 到 Push Request 5. 從PR問題表格中取出所有問題,排除嚴重等級的問題後 Comment 到 Push Request
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題 Comment 到 Push Request 6. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request 7. Commit 問題檔案
8. Commit 問題檔案 8. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
# 設計 # 設計
@@ -140,6 +139,116 @@ jobs:
issues: write issues: write
``` ```
### 6. Kilo Code
```yaml
name: AI
on:
pull_request:
types: [opened, synchronize]
jobs:
code-review:
name: 'Code Review'
runs-on: ubuntu
steps:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_BASE_URL: https://api.kilocode.com/v1
permissions:
contents: write
pull-requests: write
issues: write
```
### 7. Roo Code
```yaml
name: AI
on:
pull_request:
types: [opened, synchronize]
jobs:
code-review:
name: 'Code Review'
runs-on: ubuntu
steps:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
ROO_API_KEY: ${{ secrets.ROO_API_KEY }}
ROO_BASE_URL: https://api.roocode.com/v1
permissions:
contents: write
pull-requests: write
issues: write
```
### 8. Cline
```yaml
name: AI
on:
pull_request:
types: [opened, synchronize]
jobs:
code-review:
name: 'Code Review'
runs-on: ubuntu
steps:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
CLINE_API_KEY: ${{ secrets.CLINE_API_KEY }}
CLINE_BASE_URL: https://api.cline.dev/v1
permissions:
contents: write
pull-requests: write
issues: write
```
### 9. Continue
```yaml
name: AI
on:
pull_request:
types: [opened, synchronize]
jobs:
code-review:
name: 'Code Review'
runs-on: ubuntu
steps:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
CONTINUE_API_KEY: ${{ secrets.CONTINUE_API_KEY }}
CONTINUE_BASE_URL: https://api.continue.dev/v1
permissions:
contents: write
pull-requests: write
issues: write
```
### 10. Kade
```yaml
name: AI
on:
pull_request:
types: [opened, synchronize]
jobs:
code-review:
name: 'Code Review'
runs-on: ubuntu
steps:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
KADE_API_KEY: ${{ secrets.KADE_API_KEY }}
KADE_BASE_URL: https://api.kade.dev/v1
permissions:
contents: write
pull-requests: write
issues: write
```
### - Ollama ### - Ollama
```yaml ```yaml
+7 -14
View File
@@ -8,33 +8,26 @@
## 階段二:Findings 產生與合併 ## 階段二:Findings 產生與合併
- 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。 - 目標:各角色(style/security/performance/maintainability/testing)能產生 findings,並正確合併新舊 findings。
- 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。 - 驗收:log 中能看到每個角色 findings 數量、合併後 findings 統計,並有「Step3: merged findings total=...」等訊息。
- 完成
## 階段三:AI 去重與角色確認 ## 階段三:AI 去重與角色確認
- 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。 - 目標:嘗試呼叫 LLM 進行 findings 去重與角色確認,API 額度不足時要有降級處理 log。
- 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。 - 驗收:log 中能看到 deduplication/resolution confirmation 成功或失敗(如 402),降級時有「保留所有問題」等明確訊息。
- 完成
## 階段四:排除問題過濾 ## 階段四:findings 寫入與 comment 發布
- 目標:讀取排除問題檔案,過濾 PR 問題表格中不需要處理的問題。
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息,以及過濾後 findings 數量變化。
- 完成
## 階段五:findings 寫入與 comment 發布
- 目標:findings.jsonl 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。 - 目標:findings.jsonl 正確寫入,comment 發布順序正確(舊問題→非嚴重→嚴重),每步有 log。
- 驗收:log 中能看到 findings 寫入、comment sync 的詳細訊息與順序。 - 驗收:log 中能看到 findings 寫入、comment sync 的詳細訊息與順序。
- 完成
## 階段:記憶區 commit/push 與錯誤處理 ## 階段:記憶區 commit/push 與錯誤處理
- 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。 - 目標:記憶區能成功 commit/push,錯誤時有明確 log,流程結束有總結訊息。
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。 - 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,錯誤時有「Runner failed: ...」等明確錯誤說明。
- 完成
## 階段:阻擋嚴重問題 PR(第 8 點) ## 階段:阻擋嚴重問題 PR(第 8 點)
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。 - 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。 - 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
- 完成
--- ---
所有階段驗收通過。
每個階段都會加上明確的 log,並確保即使部分功能未完成也能降級執行、不會中斷 pipeline。
每次執行後請貼 log,我會協助 debug。
-1
View File
@@ -6,7 +6,6 @@ export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || '';
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
export function getLLMConfig() { export function getLLMConfig() {
const checks = [ const checks = [
+1 -38
View File
@@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { chatJSON } from './llm.js'; import { chatJSON } from './llm.js';
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { FINDINGS_PATH } from './config.js';
const LEVELS = ['critical', 'warning', 'info']; const LEVELS = ['critical', 'warning', 'info'];
@@ -93,40 +93,3 @@ export async function deduplicateWithAI(findings) {
return findings; return findings;
} }
} }
/**
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH
* 格式:[{ role, location, suggestion }],欄位可部分省略,省略表示萬用
*/
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 [];
}
}
/**
* 套用排除規則,過濾掉符合排除條件的 findings
* 排除條件:role/location/suggestion 皆符合(省略的欄位視為萬用)
*/
export function applyExclusions(findings, exclusions) {
if (exclusions.length === 0) return findings;
const before = findings.length;
const filtered = findings.filter(f => !exclusions.some(ex =>
(!ex.role || ex.role === f.role) &&
(!ex.location || ex.location === f.location) &&
(!ex.suggestion || String(f.suggestion).startsWith(String(ex.suggestion).slice(0, 50)))
));
console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
return filtered;
}
+34 -32
View File
@@ -1,42 +1,46 @@
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
function makeRunner(spawn) { function git(args, cwd) {
return function run(args, cwd, env) { const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
const opts = { cwd, encoding: 'utf8' }; if (result.error) throw result.error;
if (env) opts.env = env; if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
const result = spawn('git', args, opts); return (result.stdout || '').trim();
if (result.error) throw result.error;
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
return (result.stdout || '').trim();
};
} }
export async function commitAndPush(workspace, _spawnSync = spawnSync) { export async function commitAndPush(workspace) {
const run = makeRunner(_spawnSync); const remoteUrl = GITEA_SERVER_URL.replace(/\/$/, '') + `/${GITEA_REPOSITORY}.git`;
const baseUrl = GITEA_SERVER_URL.replace(/\/$/, '');
const remoteUrl = `${baseUrl}/${GITEA_REPOSITORY}.git`;
const repoDir = path.join(workspace, 'repo'); const repoDir = path.join(workspace, 'repo');
// Write a temporary askpass script that reads the token from an env var,
// so the token value never appears in the script file itself
const askpassScript = path.join(workspace, '.git-askpass.sh');
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
try { try {
if (!fs.existsSync(repoDir)) { if (!fs.existsSync(repoDir)) {
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv); // Use GIT_ASKPASS to provide token for authentication
gitWithToken(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace);
} }
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir); git(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['config', 'user.name', 'AI Review Bot'], repoDir); git(['config', 'user.name', 'AI Review Bot'], repoDir);
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); git(['fetch', 'origin', PR_HEAD_BRANCH], repoDir);
run(['checkout', PR_HEAD_BRANCH], repoDir); git(['checkout', PR_HEAD_BRANCH], repoDir);
// Helper to run git with GITEA_TOKEN via GIT_ASKPASS
import { GITEA_TOKEN } from './config.js';
function gitWithToken(args, cwd) {
const askPassScript = `#!/bin/sh\necho \"${GITEA_TOKEN}\"`;
const askPassPath = path.join(cwd, 'git-askpass.sh');
fs.writeFileSync(askPassPath, askPassScript, { mode: 0o700 });
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: { ...process.env, GIT_ASKPASS: askPassPath },
});
fs.unlinkSync(askPassPath);
if (result.error) throw result.error;
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
return (result.stdout || '').trim();
}
// 將 findings.json 從 workspace 複製到 clone 的 repo // 將 findings.json 從 workspace 複製到 clone 的 repo
const srcFindings = path.join(workspace, FINDINGS_PATH); const srcFindings = path.join(workspace, FINDINGS_PATH);
@@ -44,21 +48,19 @@ export async function commitAndPush(workspace, _spawnSync = spawnSync) {
fs.mkdirSync(path.dirname(destFindings), { recursive: true }); fs.mkdirSync(path.dirname(destFindings), { recursive: true });
fs.copyFileSync(srcFindings, destFindings); fs.copyFileSync(srcFindings, destFindings);
run(['add', FINDINGS_PATH], repoDir); git(['add', FINDINGS_PATH], repoDir);
const status = run(['status', '--porcelain'], repoDir); const status = git(['status', '--porcelain'], repoDir);
if (!status) { if (!status) {
console.log(' findings.json 無變更,跳過 commit'); console.log(' findings.json 無變更,跳過 commit');
return; return;
} }
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); const out = git(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); git(['push', remoteUrl, PR_HEAD_BRANCH], repoDir);
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
} catch (e) { } catch (e) {
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
} finally {
try { fs.unlinkSync(askpassScript); } catch {}
} }
} }
+18 -81
View File
@@ -1,93 +1,30 @@
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { commitAndPush } from './git.js'; import { commitAndPush } from './git.js';
import fs from 'fs';
import path from 'path';
// --- helpers --- // Mock dependencies and environment
function makeTmpWorkspace() { jest.mock('fs');
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-')); jest.mock('child_process', () => ({
// Pre-create repo dir so clone branch is skipped spawnSync: jest.fn(() => ({ status: 0, stdout: '', stderr: '' }))
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true }); }));
// Create a findings.json to copy
const findingsDir = path.join(ws, '.gitea/ai-review');
fs.mkdirSync(findingsDir, { recursive: true });
fs.writeFileSync(path.join(findingsDir, 'findings.json'), '[]');
return ws;
}
// Default stub: all commands succeed, status returns changes
function makeSpawn(overrides = {}) {
const calls = [];
const spawn = (cmd, args, opts) => {
const key = args[0];
calls.push({ cmd, args, opts });
if (overrides[key]) return overrides[key](args, opts);
if (key === 'status') return { status: 0, stdout: 'M .gitea/ai-review/findings.json', stderr: '', error: null };
if (key === 'commit') return { status: 0, stdout: '[feature-branch abc1234] chore', stderr: '', error: null };
return { status: 0, stdout: '', stderr: '', error: null };
};
spawn.calls = calls;
return spawn;
}
describe('commitAndPush', () => { describe('commitAndPush', () => {
let workspace; const workspace = '/tmp/workspace';
const repoDir = path.join(workspace, 'repo');
before(() => { workspace = makeTmpWorkspace(); });
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
beforeEach(() => { beforeEach(() => {
// Remove leftover askpass scripts between tests jest.clearAllMocks();
for (const f of fs.readdirSync(workspace)) { fs.existsSync.mockReturnValue(false);
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f)); fs.writeFileSync.mockImplementation(() => {});
} fs.unlinkSync.mockImplementation(() => {});
}); });
it('does not embed token in any git command argument', async () => { it('should clone repo and configure git', async () => {
const spawn = makeSpawn(); await expect(commitAndPush(workspace)).resolves.not.toThrow();
await commitAndPush(workspace, spawn);
for (const { args } of spawn.calls) {
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
}
}); });
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => { it('should not clone if repo exists', async () => {
const spawn = makeSpawn(); fs.existsSync.mockReturnValue(true);
await commitAndPush(workspace, spawn); await expect(commitAndPush(workspace)).resolves.not.toThrow();
const networkOps = ['fetch', 'push', 'clone'];
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
assert.ok(networkCalls.length > 0, 'expected at least one network git call');
for (const { args, opts } of networkCalls) {
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
}
});
it('cleans up askpass script after successful run', async () => {
await commitAndPush(workspace, 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);
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);
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));
}); });
}); });
+17 -21
View File
@@ -1,7 +1,7 @@
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig } from './config.js'; import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig } from './config.js';
import { loadRoles, getRoleIntro } from './roles.js'; import { loadRoles, getRoleIntro } from './roles.js';
import { getPRDiff, postComment } from './gitea.js'; import { getPRDiff, postComment } from './gitea.js';
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions } from './findings.js'; import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI } from './findings.js';
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { commitAndPush } from './git.js'; import { commitAndPush } from './git.js';
@@ -26,6 +26,7 @@ async function main() {
console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`);
// 取得 PR diff // 取得 PR diff
console.log('\n📋 Step1: 取得 PR Diff');
let diff; let diff;
try { try {
diff = await getPRDiff(); diff = await getPRDiff();
@@ -41,6 +42,7 @@ async function main() {
} }
// 發布角色介紹 comment // 發布角色介紹 comment
console.log('\n💬 Step1: 發布角色介紹 Comment');
try { try {
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
await postComment(intro); await postComment(intro);
@@ -48,7 +50,6 @@ async function main() {
} catch (e) { } catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
} }
console.log(' Step1 完成');
// Step2: 各角色分析 diff 產生新 findings // Step2: 各角色分析 diff 產生新 findings
console.log('\n📊 Step2: Findings 產生'); console.log('\n📊 Step2: Findings 產生');
@@ -63,43 +64,38 @@ async function main() {
} }
console.log(` Step2 完成: 新 findings 總計 ${newFindings.length}`); console.log(` Step2 完成: 新 findings 總計 ${newFindings.length}`);
// Step3: 讀取舊 findings,合併去重(含 AI 語意去重) // Step3: 讀取舊 findings,合併去重
console.log('\n🔀 Step3: Findings 合併'); console.log('\n🔀 Step3: Findings 合併');
const oldFindings = loadOldFindings(WORKSPACE); const oldFindings = loadOldFindings(WORKSPACE);
const mergedFindings = mergeFindings(oldFindings, newFindings); const mergedFindings = mergeFindings(oldFindings, newFindings);
console.log(` Step3 merged findings total=${mergedFindings.length}`); console.log(` Step3 merged findings total=${mergedFindings.length}`);
// Step3b: AI 語意去重
console.log('\n🤖 Step3b: 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(` 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})`); 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})`);
// Step4: 讀取排除問題檔案,過濾 PR 問題表格 // Step4: 寫入 findings.json,依序發布 comment
console.log('\n🚫 Step4: 排除問題過濾'); console.log('\n📝 Step4: Findings 寫入與 Comment 發布');
const exclusions = loadExclusions(WORKSPACE); saveFindings(WORKSPACE, sorted);
const filtered = applyExclusions(sorted, exclusions);
console.log(` Step4 完成: findings total=${filtered.length}`);
// Step5: 寫入 findings.json,依序發布 comment
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
saveFindings(WORKSPACE, filtered);
try { try {
await postOldFindingsComment(filtered); await postOldFindingsComment(sorted);
await postNewNonCriticalComment(filtered); await postNewNonCriticalComment(sorted);
await postNewCriticalComments(filtered); await postNewCriticalComments(sorted);
console.log(' Step5 完成'); console.log(' Step4 完成');
} catch (e) { } catch (e) {
console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`);
} }
// Step6: commit/push findings.json 到來源分支 // Step5: commit/push findings.json 到來源分支
console.log('\n💾 Step6: 記憶區 Commit/Push'); console.log('\n💾 Step5: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE); await commitAndPush(WORKSPACE);
// Step7: 有 critical 問題則 exit 1 // Step6: 有 critical 問題則 exit 1
console.log('\n🚦 Step7: 嚴重問題檢查'); console.log('\n🚦 Step6: 嚴重問題檢查');
const criticalCount = filtered.filter(f => f.level === 'critical').length; const criticalCount = sorted.filter(f => f.level === 'critical').length;
if (criticalCount > 0) { if (criticalCount > 0) {
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`); console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
console.log('='.repeat(60)); console.log('='.repeat(60));
-3
View File
@@ -2,9 +2,6 @@
"name": "ai-code-review", "name": "ai-code-review",
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": {
"test": "node --test app/git.test.js"
},
"dependencies": { "dependencies": {
"axios": "^1.6.7", "axios": "^1.6.7",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",