diff --git a/app/comments.js b/app/comments.js index 286a845..73d7c25 100644 --- a/app/comments.js +++ b/app/comments.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { postComment } from './gitea.js'; import { FINDINGS_PATH } from './config.js'; +import { ok, line } from './log.js'; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; @@ -27,7 +28,7 @@ export function saveFindings(workspace, findings, mirrorDir = null) { const fullPath = path.join(targetDir, FINDINGS_PATH); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8'); - console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`); + ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`); } } @@ -37,12 +38,12 @@ export function saveFindings(workspace, findings, mirrorDir = null) { export async function postOldFindingsComment(findings) { const old = findings.filter(f => !f.is_new); if (old.length === 0) { - console.log(' 無舊問題,跳過'); + line('無舊問題,跳過'); return; } const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`; await postComment(body); - console.log(` ✅ 舊問題 comment 發布 (${old.length} 筆)`); + ok(`舊問題 comment 發布 (${old.length} 筆)`); } /** @@ -51,12 +52,12 @@ export async function postOldFindingsComment(findings) { export async function postNewNonCriticalComment(findings) { const items = findings.filter(f => f.is_new && f.level !== 'critical'); if (items.length === 0) { - console.log(' 無新的非嚴重問題,跳過'); + line('無新的非嚴重問題,跳過'); return; } const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`; await postComment(body); - console.log(` ✅ 新問題(非嚴重)comment 發布 (${items.length} 筆)`); + ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`); } /** @@ -65,12 +66,12 @@ export async function postNewNonCriticalComment(findings) { export async function postNewCriticalComments(findings) { const criticals = findings.filter(f => f.is_new && f.level === 'critical'); if (criticals.length === 0) { - console.log(' 無新的嚴重問題,跳過'); + line('無新的嚴重問題,跳過'); return; } for (const f of criticals) { const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`; await postComment(body); - console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`); + ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`); } } diff --git a/app/findings.js b/app/findings.js index 995a9ef..7c69f50 100644 --- a/app/findings.js +++ b/app/findings.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { chatJSON } from './llm.js'; import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; +import { line, ok, warn } from './log.js'; const LEVELS = ['critical', 'warning', 'info']; @@ -9,11 +10,11 @@ const LEVELS = ['critical', 'warning', 'info']; * 用單一角色分析 diff,回傳 findings 陣列 */ export async function analyzeWithRole(role, diff) { - console.log(` [${role.name}] 開始分析...`); + line(`[${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} 個問題`); + ok(`[${role.name}] 找到 ${valid.length} 個問題`); return valid; } @@ -22,14 +23,14 @@ export async function analyzeWithRole(role, diff) { */ function readJSONArray(fullPath, label) { if (!fs.existsSync(fullPath)) { - console.log(` ${label}檔案不存在,視為空`); + warn(`${label}檔案不存在,視為空`); return []; } try { const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); return Array.isArray(data) ? data : []; } catch (e) { - console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`); + warn(`讀取${label}失敗: ${e.message},視為空`); return []; } } @@ -53,12 +54,12 @@ export function loadOldFindings(workspace) { 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}`); + line(`讀取舊 findings 檔案: ${fullPath}`); + line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`); } else { - console.log(` 舊 findings 檔案不存在: ${fullPath}`); + warn(`舊 findings 檔案不存在: ${fullPath}`); } - console.log(` 讀取舊 findings: ${old.length} 筆`); + ok(`讀取舊 findings: ${old.length} 筆`); return old; } @@ -74,7 +75,7 @@ export function mergeFindings(oldFindings, newFindings) { return true; }); const merged = [...oldFindings, ...deduped]; - console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`); + ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`); return merged; } @@ -91,7 +92,7 @@ export function sortByLevel(findings) { function fallback(label, findings, e) { const status = e.response?.status; const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message; - console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`); + warn(`${label}失敗(${reason}),降級:保留所有問題`); return findings; } @@ -111,7 +112,7 @@ export async function deduplicateWithAI(findings) { try { const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); if (Array.isArray(result) && result.length > 0) { - console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`); + ok(`AI 去重: ${findings.length} -> ${result.length} 筆`); // 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回 const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); @@ -128,13 +129,13 @@ export async function deduplicateWithAI(findings) { export function loadExclusions(workspace, repoState = null) { const fullPath = path.join(workspace, EXCLUSIONS_PATH); if (!fs.existsSync(fullPath)) { - console.log(` 排除問題檔案不存在,視為空: ${fullPath}`); + warn(`排除問題檔案不存在,視為空: ${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'}`); + line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`); } - console.log(' 讀取排除問題: raw=0 normalized=0 筆'); + ok('讀取排除問題: raw=0 normalized=0 筆'); return []; } @@ -148,14 +149,14 @@ export function loadExclusions(workspace, repoState = null) { 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}`); + line(`讀取排除問題檔案: ${fullPath}`); + line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); + line(`檔案資訊: 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}`); + warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); exclusions = []; } - console.log(` 讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`); + ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} 筆`); return exclusions; } @@ -171,7 +172,7 @@ export function applyExclusions(findings, exclusions) { const exPath = ex.location ? String(ex.location).split(':')[0] : null; return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role); })); - console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); + ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); return filtered; } @@ -190,7 +191,7 @@ export async function filterFalsePositivesWithAI(findings, exclusions = []) { try { const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); if (Array.isArray(result) && result.length > 0) { - console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); + ok(`AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); } diff --git a/app/git.js b/app/git.js index fca4321..54a48ce 100644 --- a/app/git.js +++ b/app/git.js @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; +import { line, ok, warn } from './log.js'; const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; @@ -78,11 +79,11 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) { 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}`); + ok(`repo cloned to ${repoDir}`); } else { run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); run(['checkout', PR_HEAD_BRANCH], repoDir); - console.log(` ✅ repo already exists, fetched latest`); + ok('repo already exists, fetched latest'); } return repoDir; }); @@ -130,7 +131,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, const status = run(['status', '--porcelain'], repoDir); if (!status) { - console.log(' sync files 無變更,跳過 commit'); + line('sync files 無變更,跳過 commit'); return; } @@ -139,12 +140,12 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; try { run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); - console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`); + ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`); } catch (pushErr) { - console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); + warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`); } }); } catch (e) { - console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); + warn(`Runner failed: commit/push 失敗: ${e.message}`); } } diff --git a/app/gitea.js b/app/gitea.js index 768e84d..66b484c 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -1,6 +1,7 @@ import axios from 'axios'; import https from 'https'; import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js'; +import { line, ok, warn } from './log.js'; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); @@ -46,10 +47,10 @@ export async function getCommitMessageBySha(sha) { httpsAgent, }); const message = extractCommitMessage(resp.data); - console.log(` 🔎 bot-check: commit api sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`); + line(`bot-check commit api: sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`); return message; } catch (e) { - console.log(` ⚠️ bot-check: 讀取 commit sha=${sha} 失敗: ${e.message}`); + warn(`bot-check commit api 失敗: sha=${sha} error=${e.message}`); return ''; } } @@ -63,40 +64,40 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { httpsAgent, }); const sha = resp.data?.commit?.id || resp.data?.commit?.sha || ''; - console.log(` 🔎 bot-check: branch api branch=${branch} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} sha=${sha || 'empty'} message=${extractCommitMessage(resp.data?.commit) ? 'found' : 'empty'}`); + line(`bot-check branch api: branch=${branch} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} sha=${sha || 'empty'} message=${extractCommitMessage(resp.data?.commit) ? 'found' : 'empty'}`); return await getCommitMessageBySha(sha); } catch (e) { - console.log(` ⚠️ bot-check: 讀取 branch=${branch} head commit 失敗: ${e.message}`); + warn(`bot-check branch api 失敗: branch=${branch} error=${e.message}`); return ''; } } export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { - console.log(` 🔎 bot-check: start PR_HEAD_SHA=${PR_HEAD_SHA || 'empty'} GITHUB_SHA=${process.env.GITHUB_SHA || 'empty'} sha=${sha || 'empty'} branch=${branch || 'empty'}`); + line(`bot-check start: PR_HEAD_SHA=${PR_HEAD_SHA || 'empty'} GITHUB_SHA=${process.env.GITHUB_SHA || 'empty'} sha=${sha || 'empty'} branch=${branch || 'empty'}`); const shaMessage = await getCommitMessageBySha(sha); if (sha) { - console.log(` 🔎 bot-check: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`); + line(`bot-check sha: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`); if (shaMessage.includes('[ai-review-bot]')) { - console.log(' ✅ bot-check: matched commit sha marker'); + ok('bot-check matched commit sha marker'); return true; } } else { - console.log(' 🔎 bot-check: skip sha lookup because sha is empty'); + line('bot-check skip sha lookup because sha is empty'); } const branchMessage = await getBranchHeadCommitMessage(branch); if (branch) { - console.log(` 🔎 bot-check: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`); + line(`bot-check branch: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`); if (branchMessage.includes('[ai-review-bot]')) { - console.log(' ✅ bot-check: matched branch head marker'); + ok('bot-check matched branch head marker'); return true; } } else { - console.log(' 🔎 bot-check: skip branch lookup because branch is empty'); + line('bot-check skip branch lookup because branch is empty'); } - console.log(' ℹ️ bot-check: no [ai-review-bot] marker found'); + line('bot-check no [ai-review-bot] marker found'); return false; } diff --git a/app/json.js b/app/json.js index d2b74c0..fc56649 100644 --- a/app/json.js +++ b/app/json.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { chat } from './llm.js'; +import { ok, warn, error } from './log.js'; const MAX_JSON_BYTES = 1024 * 1024; @@ -50,25 +51,25 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS fs.mkdirSync(path.dirname(fullPath), { recursive: true }); if (!fs.existsSync(fullPath)) { - console.log(` ⚠️ ${label} 不存在,將於驗證後補建`); + warn(`${label} 不存在,將於驗證後補建`); return { exists: false, valid: false, repaired: false }; } try { JSON.parse(readJSONText(fullPath, label)); - console.log(` ✅ ${label} JSON 格式正確`); + ok(`${label} JSON 格式正確`); return { exists: true, valid: true, repaired: false }; } catch (e) { - console.error(` ❌ ${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`); + error(`${label} JSON 格式錯誤: ${e.message},嘗試透過 AI 修正...`); try { const original = readJSONText(fullPath, label); const repaired = await repairer(fullPath, label, original); fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8'); JSON.parse(readJSONText(fullPath, label)); - console.log(` ✅ ${label} 已由 AI 修正並通過再次驗證`); + ok(`${label} 已由 AI 修正並通過再次驗證`); return { exists: true, valid: true, repaired: true }; } catch (repairErr) { - console.error(` ❌ ${label} 修正失敗: ${repairErr.message}`); + error(`${label} 修正失敗: ${repairErr.message}`); throw repairErr; } } @@ -82,6 +83,6 @@ export function ensureJSONArrayFileExists(fullPath, label) { if (fs.existsSync(fullPath)) return false; fs.writeFileSync(fullPath, '[]\n', 'utf8'); - console.log(` ⚠️ ${label} 不存在,已建立空陣列`); + warn(`${label} 不存在,已建立空陣列`); return true; } diff --git a/app/llm.js b/app/llm.js index 87ace9d..e1a401a 100644 --- a/app/llm.js +++ b/app/llm.js @@ -1,11 +1,12 @@ import axios from 'axios'; import { getLLMConfig } from './config.js'; +import { line, error } from './log.js'; export async function chat(systemPrompt, userContent) { const { provider, apiKeys, baseURL, model } = getLLMConfig(); if (!provider) throw new Error('未設定任何 LLM API Key'); - console.log(` [LLM] provider=${provider} model=${model}`); + line(`[LLM] provider=${provider} model=${model}`); const headers = { 'Content-Type': 'application/json' }; if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; @@ -21,10 +22,10 @@ export async function chat(systemPrompt, userContent) { ); return resp.data.choices[0].message.content; } catch (e) { - console.log(` [LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); + line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); } } - console.error(' [LLM] 所有 API Key 均失敗,終止流程'); + error('[LLM] 所有 API Key 均失敗,終止流程'); process.exit(1); } @@ -33,7 +34,7 @@ export async function chatJSON(systemPrompt, userContent) { try { return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); } catch (e) { - console.log(` [LLM] JSON 解析失敗: ${e.message}`); + line(`[LLM] JSON 解析失敗: ${e.message}`); return []; } } diff --git a/app/log.js b/app/log.js new file mode 100644 index 0000000..cde500f --- /dev/null +++ b/app/log.js @@ -0,0 +1,23 @@ +export function section(title) { + console.log(`\n=== ${title} ===`); +} + +export function step(stepName, title) { + console.log(`\n[${stepName}] ${title}`); +} + +export function line(message) { + console.log(` - ${message}`); +} + +export function ok(message) { + console.log(` ✓ ${message}`); +} + +export function warn(message) { + console.log(` ! ${message}`); +} + +export function error(message) { + console.error(` x ${message}`); +} diff --git a/app/main.js b/app/main.js index ea84459..f7fc6ba 100644 --- a/app/main.js +++ b/app/main.js @@ -6,121 +6,115 @@ import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplica import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; +import { section, step, line, ok, warn, error } from './log.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; async function main() { - console.log('='.repeat(60)); - console.log('🚀 Step1: Pipeline 啟動'); - console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); - console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); + section('AI Code Review Pipeline'); + step('Step1', 'Pipeline 啟動'); + line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); + line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || ''; const headMessage = await getCommitMessageBySha(headSha); const headOutcome = getBotReviewOutcome(headMessage); - console.log(` 🔎 head check: sha=${headSha || 'empty'} outcome=${headOutcome}`); + line(`head check: sha=${headSha || 'empty'} outcome=${headOutcome}`); if (headMessage.includes('[ai-review-bot]') && headOutcome === 'failure') { - console.log(' ❌ 偵測到 [ai-review-bot][failure],直接讓 workflow 失敗'); - console.log('='.repeat(60)); + error('偵測到 [ai-review-bot][failure],直接讓 workflow 失敗'); + section('Pipeline 結束'); process.exit(1); } if (await shouldSkipBotCommit()) { - console.log(' 🤖 偵測到 [ai-review-bot] 自動提交,直接完成 action'); - console.log('='.repeat(60)); + ok('偵測到 [ai-review-bot] 自動提交,直接完成 action'); + section('Pipeline 結束'); process.exit(0); } const { provider, baseURL, model } = getLLMConfig(); if (!provider) { - console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); + error('未設定任何 LLM API Key,請檢查 action inputs'); process.exit(1); } - console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`); + line(`LLM: provider=${provider} model=${model} base_url=${baseURL}`); const roles = loadRoles(); - console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); + line(`已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); let diff; try { diff = await getPRDiff(); - console.log(` diff 長度: ${diff.length} 字元`); + line(`diff 長度: ${diff.length} 字元`); } catch (e) { - console.error(` ❌ 取得 diff 失敗: ${e.message}`); + error(`取得 diff 失敗: ${e.message}`); process.exit(1); } if (!diff.trim()) { - console.log(' ⚠️ diff 為空,無需審查'); + warn('diff 為空,無需審查'); process.exit(0); } try { const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; await postComment(intro); - console.log(' ✅ 角色介紹 comment 發布成功'); + ok('角色介紹 comment 發布成功'); } catch (e) { - console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); + warn(`comment 發布失敗(繼續執行): ${e.message}`); } - // Step2: 各角色分析 diff 產生新 findings - console.log('\n📊 Step2: Findings 產生'); + step('Step2', 'Findings 產生'); const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; for (let i = 0; i < results.length; i++) { if (results[i].status === 'fulfilled') { newFindings.push(...results[i].value); } else { - console.log(` ⚠️ [${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); + warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); } } - console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`); + ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`); - // Step4: 讀取舊 findings,合併去重(含 AI 語意去重) - console.log('\n🔀 Step3: Findings 合併'); - // Clone repo 以讀取舊 findings 與排除清單 + step('Step3', 'Findings 合併'); let repoDir; try { repoDir = cloneRepo(WORKSPACE); } catch (e) { - console.log(` ⚠️ clone repo 失敗(繼續執行): ${e.message}`); + warn(`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}`); + line(`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}`); + ok(`Step3 merged findings total=${mergedFindings.length}`); - console.log('\n🤖 Step3b: AI 語意去重'); + step('Step3b', 'AI 語意去重'); const deduped = await deduplicateWithAI(mergedFindings); 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})`); + ok(`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})`); - // Step5: 讀取排除問題檔案,過濾 PR 問題表格,並請 AI 判斷誤報 - console.log('\n🚫 Step4: AI 排除問題過濾'); - // 輸入至 findings 用於 AI 誤報過濾,exclusions 同時作為已知誤報參考 + step('Step4', 'AI 排除問題過濾'); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState); const ruleFiltered = applyExclusions(sorted, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); - console.log(` Step4 完成: findings total=${filtered.length}`); + ok(`Step4 完成: findings total=${filtered.length}`); - // Step6: 寫入 findings.json,依序發布 comment - console.log('\n📝 Step5: Findings 寫入與 Comment 發布'); + step('Step5', 'Findings 寫入與 Comment 發布'); const reviewDir = repoDir || WORKSPACE; saveFindings(WORKSPACE, filtered, reviewDir); try { await postOldFindingsComment(filtered); await postNewNonCriticalComment(filtered); await postNewCriticalComments(filtered); - console.log(' Step5 完成'); + ok('Step5 完成'); } catch (e) { - console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); + warn(`comment 發布失敗(繼續執行): ${e.message}`); } - // Step7: 驗證 findings.json 與 exclusions.json 為合法 JSON - console.log('\n🔎 Step6: JSON 格式驗證'); + step('Step6', 'JSON 格式驗證'); const missingPaths = []; for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { const fullPath = path.join(reviewDir, relPath); @@ -136,26 +130,24 @@ async function main() { ensureJSONArrayFileExists(fullPath, relPath); } - // Step7: commit/push findings.json 到來源分支 - console.log('\n💾 Step7: 記憶區 Commit/Push'); + step('Step7', '記憶區 Commit/Push'); const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; - console.log(` 🔎 review outcome=${reviewOutcome}`); + line(`review outcome=${reviewOutcome}`); await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); - // Step9: 有 critical 問題則 exit 1 - console.log('\n🚦 Step8: 嚴重問題檢查'); + step('Step8', '嚴重問題檢查'); const criticalCount = filtered.filter(f => f.level === 'critical').length; if (criticalCount > 0) { - console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`); - console.log('='.repeat(60)); + error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`); + section('Pipeline 結束'); process.exit(1); } - console.log(' ✅ 無嚴重問題'); - console.log('\n✅ Pipeline 完成'); - console.log('='.repeat(60)); + ok('無嚴重問題'); + ok('Pipeline 完成'); + section('Pipeline 結束'); } main().catch(e => { - console.error('❌ Runner failed:', e.message); + error(`Runner failed: ${e.message}`); process.exit(1); });