Compare commits
2 Commits
v0.1.3
...
v0.1.4-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6c791a82 | |||
| 222de4b369 |
@@ -31,6 +31,7 @@ jobs:
|
||||
- name: AI Code Review
|
||||
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
|
||||
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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
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;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
||||
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
||||
|
||||
# 使用說明
|
||||
|
||||
|
||||
+33
-6
@@ -40,11 +40,24 @@ function normalizeExclusions(data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatFileTime(mtimeMs) {
|
||||
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
||||
return new Date(mtimeMs).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
|
||||
*/
|
||||
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} 筆`);
|
||||
return old;
|
||||
}
|
||||
@@ -112,23 +125,37 @@ export async function deduplicateWithAI(findings) {
|
||||
/**
|
||||
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
||||
*/
|
||||
export function loadExclusions(workspace) {
|
||||
export function loadExclusions(workspace, repoState = null) {
|
||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(' 排除問題檔案不存在,視為空');
|
||||
console.log(' 讀取排除問題: 0 筆');
|
||||
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},視為空`);
|
||||
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
|
||||
exclusions = [];
|
||||
}
|
||||
console.log(` 讀取排除問題: ${exclusions.length} 筆`);
|
||||
console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`);
|
||||
return exclusions;
|
||||
}
|
||||
|
||||
|
||||
+50
-2
@@ -3,17 +3,25 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { loadExclusions, applyExclusions } from './findings.js';
|
||||
import { EXCLUSIONS_PATH } from './config.js';
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -47,4 +55,44 @@ describe('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)}`)));
|
||||
});
|
||||
});
|
||||
|
||||
+17
@@ -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)
|
||||
*/
|
||||
|
||||
+6
-2
@@ -4,7 +4,7 @@ import { loadRoles, getRoleIntro } from './roles.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';
|
||||
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||
|
||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||
@@ -69,6 +69,10 @@ async function main() {
|
||||
} catch (e) {
|
||||
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 mergedFindings = mergeFindings(oldFindings, newFindings);
|
||||
console.log(` Step3 merged findings total=${mergedFindings.length}`);
|
||||
@@ -81,7 +85,7 @@ async function main() {
|
||||
// Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報
|
||||
console.log('\n🚫 Step4: AI 排除問題過濾');
|
||||
// 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考
|
||||
const exclusions = loadExclusions(repoDir || WORKSPACE);
|
||||
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState);
|
||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||
console.log(` Step4 完成: findings total=${filtered.length}`);
|
||||
|
||||
Reference in New Issue
Block a user