Compare commits

...

5 Commits

7 changed files with 212 additions and 14 deletions
+1
View File
@@ -31,6 +31,7 @@ jobs:
- name: AI Code Review - name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }}
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
+5 -4
View File
@@ -6,8 +6,8 @@
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
3. 讀取所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json` 存在於使用此 Action 的專案固定位置)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案 3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案
4. 讀取排除問題檔案(`.gitea/ai-review/exclusions.json` 存在於使用此 Action 的專案固定位置),用來過濾PR問題表格中不需要處理的問題 4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾PR問題表格中不需要處理的問題
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. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
@@ -23,8 +23,9 @@
5. 將提示詞放到 ./app/prompts 內供程式讀取 5. 將提示詞放到 ./app/prompts 內供程式讀取
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1 6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
7. 讀取 Git Diff 時排除 `.gitea/``.amazonq/``.claude/``.codex/``.gemini/``.github/` 資料夾,以及 `CLAUDE.md``GEMINI.md``TODO.md``README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼 7. 讀取 Git Diff 時排除 `.gitea/``.amazonq/``.claude/``.codex/``.gemini/``.github/` 資料夾,以及 `CLAUDE.md``GEMINI.md``TODO.md``README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
8. 階段七驗證 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 8. 階段七驗證來源分支中的 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
# 使用說明 # 使用說明
@@ -227,4 +228,4 @@ Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
### 版本包含 ### 版本包含
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。 提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json``exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
+53 -6
View File
@@ -34,11 +34,30 @@ function readJSONArray(fullPath, label) {
} }
} }
function normalizeExclusions(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
return [];
}
function formatFileTime(mtimeMs) {
if (!Number.isFinite(mtimeMs)) return 'unknown';
return new Date(mtimeMs).toISOString();
}
/** /**
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH * 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH
*/ */
export function loadOldFindings(workspace) { export function loadOldFindings(workspace) {
const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false })); const fullPath = path.join(workspace, FINDINGS_PATH);
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
if (fs.existsSync(fullPath)) {
const stat = fs.statSync(fullPath);
console.log(` 讀取舊 findings 檔案: ${fullPath}`);
console.log(` 舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
} else {
console.log(` 舊 findings 檔案不存在: ${fullPath}`);
}
console.log(` 讀取舊 findings: ${old.length}`); console.log(` 讀取舊 findings: ${old.length}`);
return old; return old;
} }
@@ -104,11 +123,39 @@ export async function deduplicateWithAI(findings) {
} }
/** /**
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH * 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH
*/ */
export function loadExclusions(workspace) { export function loadExclusions(workspace, repoState = null) {
const exclusions = readJSONArray(path.join(workspace, EXCLUSIONS_PATH), '排除問題'); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
console.log(` 讀取排除問題: ${exclusions.length}`); if (!fs.existsSync(fullPath)) {
console.log(` 排除問題檔案不存在,視為空: ${fullPath}`);
if (repoState) {
const branch = repoState.branch || 'detached';
const shortSha = repoState.shortSha || repoState.headSha || 'unknown';
console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`);
}
console.log(' 讀取排除問題: raw=0 normalized=0 筆');
return [];
}
let exclusions = [];
let rawCount = 0;
try {
const stat = fs.statSync(fullPath);
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0;
exclusions = normalizeExclusions(data);
const branch = repoState?.branch || 'detached';
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
const commitTime = repoState?.commitTime || 'unknown';
console.log(` 讀取排除問題檔案: ${fullPath}`);
console.log(` 來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`);
console.log(` 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`);
} catch (e) {
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
exclusions = [];
}
console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length}`);
return exclusions; return exclusions;
} }
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadOldFindings, loadExclusions, applyExclusions } from './findings.js';
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
describe('findings exclusions', () => {
let workspace;
let logs;
let originalLog;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-'));
logs = [];
originalLog = console.log;
console.log = (...args) => {
logs.push(args.join(' '));
};
});
afterEach(() => {
console.log = originalLog;
fs.rmSync(workspace, { recursive: true, force: true });
});
it('loads excluded_findings wrapper format', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify({
excluded_findings: [
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
],
}, null, 2));
const exclusions = loadExclusions(workspace);
assert.equal(exclusions.length, 1);
assert.equal(exclusions[0].location, 'entrypoint.sh:180');
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
});
it('applies exclusions loaded from wrapper format', () => {
const findings = [
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
{ location: 'README.md:12', role: 'Maya', suggestion: 'keep' },
];
const exclusions = [
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
];
const filtered = applyExclusions(findings, exclusions);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].location, 'README.md:12');
});
it('logs exclusions file metadata and repo state when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([
{ location: 'entrypoint.sh:180', suggestion: 'ignore' },
{ location: 'README.md:12', suggestion: 'ignore' },
], null, 2));
const repoState = {
branch: 'feat/test',
shortSha: 'abc1234',
commitTime: '2026-05-15T09:29:49.817Z',
repoDir: path.join(workspace, 'repo'),
};
const exclusions = loadExclusions(workspace, repoState);
assert.equal(exclusions.length, 2);
assert.ok(logs.some(line => line.includes(`讀取排除問題檔案: ${fullPath}`)));
assert.ok(logs.some(line => line.includes('來源分支狀態: branch=feat/test commit=abc1234')));
assert.ok(logs.some(line => line.includes('raw=2 normalized=2')));
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
});
it('logs findings file metadata when loading old findings', () => {
const fullPath = path.join(workspace, FINDINGS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
], null, 2));
const findings = loadOldFindings(workspace);
assert.equal(findings.length, 1);
assert.equal(findings[0].is_new, false);
assert.ok(logs.some(line => line.includes(`讀取舊 findings 檔案: ${fullPath}`)));
assert.ok(logs.some(line => line.includes('舊 findings 檔案資訊: bytes=')));
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
});
});
+23 -2
View File
@@ -41,6 +41,23 @@ function withAskpass(workspace, fn) {
} }
} }
function readGitOutput(run, args, cwd, env) {
try {
return run(args, cwd, env);
} catch {
return '';
}
}
export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir);
const branch = readGitOutput(run, ['branch', '--show-current'], repoDir);
const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir);
return { repoDir, branch, headSha, shortSha, commitTime };
}
/** /**
* Clone PR head branch to workspace/repo (idempotent) * Clone PR head branch to workspace/repo (idempotent)
*/ */
@@ -109,8 +126,12 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); const out = run(['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); try {
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
} catch (pushErr) {
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`);
}
}); });
} catch (e) { } catch (e) {
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
+26
View File
@@ -150,6 +150,32 @@ describe('commitAndPush', () => {
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, path.join(workspace, 'repo'), failSpawn, sourceRoot)); await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
}); });
it('logs push failures separately from commit failures', async () => {
const repoDir = path.join(workspace, 'repo');
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true });
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
const spawn = makeSpawn({
push: () => ({ status: 1, stdout: '', stderr: 'remote: error: pre-receive hook declined', error: null }),
});
const logs = [];
const originalLog = console.log;
console.log = (...args) => { logs.push(args.join(' ')); };
try {
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
} finally {
console.log = originalLog;
}
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
});
}); });
describe('cloneRepo', () => { describe('cloneRepo', () => {
+6 -2
View File
@@ -4,7 +4,7 @@ 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, 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, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
@@ -69,6 +69,10 @@ async function main() {
} catch (e) { } catch (e) {
console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`); console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`);
} }
const repoState = repoDir ? getRepoState(repoDir) : null;
if (repoState) {
console.log(` repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`);
}
const oldFindings = loadOldFindings(repoDir || WORKSPACE); const oldFindings = loadOldFindings(repoDir || 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}`);
@@ -81,7 +85,7 @@ async function main() {
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報 // Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
console.log('\n🚫 Step4: AI 排除問題過濾'); console.log('\n🚫 Step4: AI 排除問題過濾');
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考 // 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
const exclusions = loadExclusions(repoDir || WORKSPACE); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState);
const ruleFiltered = applyExclusions(sorted, exclusions); const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
console.log(` Step4 完成: findings total=${filtered.length}`); console.log(` Step4 完成: findings total=${filtered.length}`);