Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea50d76887 | |||
| dbc387692d |
@@ -4,8 +4,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
types: [opened, synchronize]
|
||||
jobs:
|
||||
version:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
||||
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
||||
7. 讀取 Git Diff 時排除 `.gitea/` 資料夾內的所有檔案,避免 AI 分析 workflow 設定等非業務程式碼
|
||||
8. 階段五完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 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 用量
|
||||
|
||||
# 使用說明
|
||||
@@ -198,4 +198,4 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
```
|
||||
```
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
- 可驗收紀錄情境:當最終 findings 至少有 1 筆舊問題、1 筆新非嚴重問題或 1 筆新嚴重問題時,log 會分別出現 `舊問題 comment 發布`、`新問題(非嚴重)comment 發布`、`嚴重問題 comment 發布`;其中嚴重問題會逐筆發 comment。
|
||||
|
||||
## 階段七:階段六後驗證 JSON 格式
|
||||
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試重置為空陣列並備份原檔,修正失敗才 exit 1。
|
||||
- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有「嘗試修正」訊息與備份路徑,修正失敗時 workflow 狀態為失敗。
|
||||
- 目標:階段六完成後驗證 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`。
|
||||
- 驗收:log 中能看到兩個檔案的驗證結果(成功或失敗),格式錯誤時有 AI 修正嘗試與修正後再次驗證的訊息;若檔案不存在,會在驗證完成後看到建立並寫入 `[]` 的訊息;修正失敗時 workflow 狀態為失敗。
|
||||
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
||||
|
||||
## 階段八:記憶區 commit/push 與錯誤處理
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { chat } from './llm.js';
|
||||
|
||||
function stripCodeFence(text) {
|
||||
return String(text)
|
||||
.trim()
|
||||
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
|
||||
.replace(/```$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function repairJSONArrayWithAI(fullPath, label, rawText) {
|
||||
const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。
|
||||
只回傳修正後的 JSON 陣列內容,不要使用 markdown code fence,不要加任何解釋。
|
||||
如果原內容不是陣列,也請盡量修成合理的 JSON 陣列;若無法判斷,回傳 []。`;
|
||||
const userContent = `檔案: ${label}\n原始內容:\n${rawText}`;
|
||||
const repaired = await chat(systemPrompt, userContent);
|
||||
return stripCodeFence(repaired);
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證 JSON 陣列檔案是否存在且格式正確。
|
||||
* 若格式錯誤,直接嘗試透過 AI 修復,修復後再次檢查;
|
||||
* 第二次檢查仍失敗才丟出例外。
|
||||
* 若檔案不存在,回傳 exists=false,交由呼叫端決定是否補檔。
|
||||
*/
|
||||
export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(` ⚠️ ${label} 不存在,將於驗證後補建`);
|
||||
return { exists: false, valid: false, repaired: false };
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
console.log(` ✅ ${label} JSON 格式正確`);
|
||||
return { exists: true, valid: true, repaired: false };
|
||||
} catch (e) {
|
||||
console.error(` ❌ ${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`);
|
||||
try {
|
||||
const original = fs.readFileSync(fullPath, 'utf8');
|
||||
const repaired = await repairer(fullPath, label, original);
|
||||
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
|
||||
JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
console.log(` ✅ ${label} 已由 AI 修正並通過再次驗證`);
|
||||
return { exists: true, valid: true, repaired: true };
|
||||
} catch (repairErr) {
|
||||
console.error(` ❌ ${label} 修正失敗: ${repairErr.message}`);
|
||||
throw repairErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 若檔案不存在則建立空陣列。
|
||||
*/
|
||||
export function ensureJSONArrayFileExists(fullPath, label) {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
if (fs.existsSync(fullPath)) return false;
|
||||
|
||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
||||
console.log(` ⚠️ ${label} 不存在,已建立空陣列`);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||
|
||||
describe('json helpers', () => {
|
||||
let workspace;
|
||||
|
||||
beforeEach(() => {
|
||||
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'json-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports missing file without creating it', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
|
||||
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json');
|
||||
|
||||
assert.deepEqual(result, { exists: false, valid: false, repaired: false });
|
||||
assert.equal(fs.existsSync(fullPath), false);
|
||||
});
|
||||
|
||||
it('creates an empty array file when asked to ensure existence', () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
|
||||
const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json');
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
||||
});
|
||||
|
||||
it('keeps a valid JSON array unchanged', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/exclusions.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
||||
|
||||
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/exclusions.json');
|
||||
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: false });
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
||||
});
|
||||
|
||||
it('repairs invalid JSON using AI output and rewrites the file', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
||||
|
||||
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async (_fullPath, _label, original) => {
|
||||
assert.equal(original, '{broken');
|
||||
return '[{"fixed":true}]';
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
||||
});
|
||||
});
|
||||
+10
-18
@@ -1,4 +1,3 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
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';
|
||||
@@ -6,6 +5,7 @@ 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 { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||
|
||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||
|
||||
@@ -100,29 +100,21 @@ async function main() {
|
||||
|
||||
// Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON
|
||||
console.log('\n🔎 Step6: JSON 格式驗證');
|
||||
const missingPaths = [];
|
||||
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
||||
const fullPath = path.join(repoDir || WORKSPACE, relPath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(` ⚠️ ${relPath} 不存在,跳過驗證`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
console.log(` ✅ ${relPath} JSON 格式正確`);
|
||||
} catch (e) {
|
||||
console.error(` ❌ ${relPath} JSON 格式錯誤: ${e.message},嘗試修正...`);
|
||||
try {
|
||||
const backupPath = fullPath + '.bak';
|
||||
fs.copyFileSync(fullPath, backupPath);
|
||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
||||
console.log(` ✅ ${relPath} 已重置為空陣列(原檔備份至 ${relPath}.bak)`);
|
||||
} catch (repairErr) {
|
||||
console.error(` ❌ ${relPath} 修正失敗: ${repairErr.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await validateJSONArrayFile(fullPath, relPath);
|
||||
if (!result.exists) missingPaths.push({ fullPath, relPath });
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { fullPath, relPath } of missingPaths) {
|
||||
ensureJSONArrayFileExists(fullPath, relPath);
|
||||
}
|
||||
|
||||
// Step7: commit/push findings.json 到來源分支
|
||||
console.log('\n💾 Step7: 記憶區 Commit/Push');
|
||||
await commitAndPush(WORKSPACE, repoDir);
|
||||
|
||||
Reference in New Issue
Block a user