diff --git a/app/comments.js b/app/comments.js index 779a177..a8f2198 100644 --- a/app/comments.js +++ b/app/comments.js @@ -63,7 +63,7 @@ export async function postNewCriticalComments(findings) { return; } for (const f of criticals) { - const body = `## 🚨 嚴重問題\n\n| 審查員 | 位置 | 建議 |\n|--------|------|------|\n| ${f.role} | ${f.location} | ${f.suggestion} |`; + const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`; await postComment(body); console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`); } diff --git a/app/findings.js b/app/findings.js index f381cfa..8a6a5cb 100644 --- a/app/findings.js +++ b/app/findings.js @@ -11,7 +11,6 @@ const LEVELS = ['critical', 'warning', 'info']; export async function analyzeWithRole(role, diff) { console.log(` [${role.name}] 開始分析...`); const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`); - // 確保每筆都有必要欄位,並標記為新問題 const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion) .map(f => ({ ...f, is_new: true })); console.log(` [${role.name}] 找到 ${valid.length} 個問題`); @@ -40,7 +39,6 @@ export function loadOldFindings(workspace) { /** * 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複 - * 舊問題保留,新問題若與舊問題重複則捨棄 */ export function mergeFindings(oldFindings, newFindings) { const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`; @@ -63,8 +61,17 @@ export function sortByLevel(findings) { } /** - * 呼叫 LLM 進行語意去重,回傳去重後的 findings - * 失敗時降級回傳原始 findings + * AI 呼叫失敗時的統一降級處理 + */ +function fallback(label, findings, e) { + const status = e.response?.status; + const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message; + console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`); + return findings; +} + +/** + * 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings */ export async function deduplicateWithAI(findings) { if (findings.length === 0) return findings; @@ -74,29 +81,20 @@ export async function deduplicateWithAI(findings) { 保留等級較高的版本,優先保留 critical > warning > info。 只回傳去重後的 JSON 陣列,不要有其他文字。`; - const userContent = `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`; - try { - const result = await chatJSON(systemPrompt, userContent); + const result = await chatJSON(systemPrompt, `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`); if (Array.isArray(result) && result.length > 0) { console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`); return result; } throw new Error('AI 回傳空陣列'); } catch (e) { - const status = e.response?.status; - if (status === 402 || status === 429) { - console.log(` ⚠️ AI 去重失敗(${status} 額度/限流),降級:保留所有問題`); - } else { - console.log(` ⚠️ AI 去重失敗(${e.message}),降級:保留所有問題`); - } - return findings; + return fallback('AI 去重', findings, e); } } /** * 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH) - * 格式:[{ role, location, suggestion }],欄位可部分省略,省略表示萬用 */ export function loadExclusions(workspace) { const fullPath = path.join(workspace, EXCLUSIONS_PATH); @@ -125,17 +123,14 @@ export function applyExclusions(findings, exclusions) { const filtered = findings.filter(f => !exclusions.some(ex => { const fPath = String(f.location).split(':')[0]; const exPath = ex.location ? String(ex.location).split(':')[0] : null; - return (!exPath || fPath === exPath) && - (!ex.role || ex.role === f.role); + return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role); })); console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); return filtered; } /** - * 呼叫 AI 判斷哪些問題是誤報或不需處理,回傳需保留的 findings - * exclusions 為已知誤報清單,供 AI 參考判斷 - * 失敗時降級回傳原始 findings + * 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings */ export async function filterFalsePositivesWithAI(findings, exclusions = []) { if (findings.length === 0) return findings; @@ -152,22 +147,14 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) { 3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似) 只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`; - const userContent = `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`; - try { - const result = await chatJSON(systemPrompt, userContent); + const result = await chatJSON(systemPrompt, `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`); if (Array.isArray(result) && result.length > 0) { console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); return result; } throw new Error('AI 回傳空陣列或非陣列'); } catch (e) { - const status = e.response?.status; - if (status === 402 || status === 429) { - console.log(` ⚠️ AI 誤報過濾失敗(${status} 額度/限流),降級:保留所有問題`); - } else { - console.log(` ⚠️ AI 誤報過濾失敗(${e.message}),降級:保留所有問題`); - } - return findings; + return fallback('AI 誤報過濾', findings, e); } } diff --git a/app/git.js b/app/git.js index 5006d88..b182ba0 100644 --- a/app/git.js +++ b/app/git.js @@ -14,20 +14,26 @@ function makeRunner(spawn) { }; } +function withAskpass(workspace, fn) { + 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 { + return fn(credEnv); + } finally { + try { fs.unlinkSync(askpassScript); } catch {} + } +} + /** * Clone PR head branch to workspace/repo (idempotent) */ export function cloneRepo(workspace, _spawnSync = spawnSync) { const run = makeRunner(_spawnSync); - const baseUrl = GITEA_SERVER_URL.replace(/\/$/, ''); - const remoteUrl = `${baseUrl}/${GITEA_REPOSITORY}.git`; + const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; const repoDir = path.join(workspace, 'repo'); - 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 { + return withAskpass(workspace, credEnv => { if (!fs.existsSync(repoDir)) { run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv); console.log(` ✅ repo cloned to ${repoDir}`); @@ -36,57 +42,40 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) { run(['checkout', PR_HEAD_BRANCH], repoDir); console.log(` ✅ repo already exists, fetched latest`); } - } finally { - try { fs.unlinkSync(askpassScript); } catch {} - } - return repoDir; + return repoDir; + }); } export async function commitAndPush(workspace, _spawnSync = spawnSync) { const run = makeRunner(_spawnSync); - - const baseUrl = GITEA_SERVER_URL.replace(/\/$/, ''); - const remoteUrl = `${baseUrl}/${GITEA_REPOSITORY}.git`; - 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 }; + const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; try { - if (!fs.existsSync(repoDir)) { - run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv); - } + const repoDir = cloneRepo(workspace, _spawnSync); - run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir); - run(['config', 'user.name', 'AI Review Bot'], repoDir); - run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); - run(['checkout', PR_HEAD_BRANCH], repoDir); + await withAskpass(workspace, async credEnv => { + run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir); + run(['config', 'user.name', 'AI Review Bot'], repoDir); - // 將 findings.json 從 workspace 複製到 clone 的 repo - const srcFindings = path.join(workspace, FINDINGS_PATH); - const destFindings = path.join(repoDir, FINDINGS_PATH); - fs.mkdirSync(path.dirname(destFindings), { recursive: true }); - fs.copyFileSync(srcFindings, destFindings); + const srcFindings = path.join(workspace, FINDINGS_PATH); + const destFindings = path.join(repoDir, FINDINGS_PATH); + fs.mkdirSync(path.dirname(destFindings), { recursive: true }); + fs.copyFileSync(srcFindings, destFindings); - run(['add', FINDINGS_PATH], repoDir); + run(['add', FINDINGS_PATH], repoDir); - const status = run(['status', '--porcelain'], repoDir); - if (!status) { - console.log(' findings.json 無變更,跳過 commit'); - return; - } + const status = run(['status', '--porcelain'], repoDir); + if (!status) { + console.log(' findings.json 無變更,跳過 commit'); + return; + } - const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); - const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; - run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); - console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); + const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); + const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; + run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); + console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); + }); } catch (e) { console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); - } finally { - try { fs.unlinkSync(askpassScript); } catch {} } } diff --git a/app/gitea.js b/app/gitea.js index 8c319b0..e35ccab 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -12,9 +12,8 @@ export async function getPRDiff() { } function filterDiff(diff, excludePrefixes) { - const blocks = diff.split(/(?=^diff --git )/m); - return blocks - .filter(block => !excludePrefixes.some(prefix => block.match(new RegExp(`^diff --git a/${prefix.replace(/\//g, '\\/')}`)))) + return diff.split(/(?=^diff --git )/m) + .filter(block => !excludePrefixes.some(p => block.startsWith(`diff --git a/${p}`))) .join(''); } diff --git a/app/llm.js b/app/llm.js index 4bf932f..87ace9d 100644 --- a/app/llm.js +++ b/app/llm.js @@ -29,12 +29,11 @@ export async function chat(systemPrompt, userContent) { } export async function chatJSON(systemPrompt, userContent) { + const text = await chat(systemPrompt, userContent); try { - let text = await chat(systemPrompt, userContent); - text = text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim(); - return JSON.parse(text); + return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); } catch (e) { - console.log(` [LLM] 解析失敗: ${e.message}`); + console.log(` [LLM] JSON 解析失敗: ${e.message}`); return []; } } diff --git a/app/main.js b/app/main.js index 67a797d..e25b839 100644 --- a/app/main.js +++ b/app/main.js @@ -13,7 +13,6 @@ async function main() { console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); - // 偵測 LLM const { provider, baseURL, model } = getLLMConfig(); if (!provider) { console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); @@ -21,11 +20,9 @@ async function main() { } console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`); - // 載入角色 const roles = loadRoles(); console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); - // 取得 PR diff let diff; try { diff = await getPRDiff(); @@ -40,7 +37,6 @@ async function main() { process.exit(0); } - // 發布角色介紹 comment try { const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; await postComment(intro); @@ -48,7 +44,6 @@ async function main() { } catch (e) { console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); } - console.log(' Step1 完成'); // Step2: 各角色分析 diff 產生新 findings console.log('\n📊 Step2: Findings 產生'); @@ -65,7 +60,6 @@ async function main() { // Step3: 讀取舊 findings,合併去重(含 AI 語意去重) console.log('\n🔀 Step3: Findings 合併'); - // Clone repo 以讀取舊 findings 與排除清單 let repoDir; try { repoDir = cloneRepo(WORKSPACE); @@ -91,7 +85,6 @@ async function main() { // Step5: 寫入 findings.json,依序發布 comment console.log('\n📝 Step5: Findings 寫入與 Comment 發布'); saveFindings(WORKSPACE, filtered); - try { await postOldFindingsComment(filtered); await postNewNonCriticalComment(filtered); @@ -114,7 +107,6 @@ async function main() { process.exit(1); } console.log(' ✅ 無嚴重問題'); - console.log('\n✅ Pipeline 完成'); console.log('='.repeat(60)); }