import fs from 'fs'; import path from 'path'; import { postComment, postPullReviewComment, postPullReview } from './gitea.js'; import { FINDINGS_PATH } from './config.js'; import { ok, line, warn } from './log.js'; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; const LEVEL_ORDER = ['critical', 'warning', 'info']; /** * 將單一 finding 格式化為 Markdown 表格的一列(等級|審查員|位置|建議)。 * * @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f * 單筆審查問題物件。`level` 若不在 critical/warning/info 之內,emoji 留空、標籤回退為原始 level 值; * `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 會拋 TypeError(需人工確認是否需防呆)。 * @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串。 * @remarks 內部輔助函式,供 {@link buildTable} 逐列組裝表格使用,本身不含換行。 */ function findingRow(f) { return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`; } /** * 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。 * * @param {Array} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。 * @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。 * @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。 */ function buildTable(findings) { const rows = findings.map(findingRow).join('\n'); return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`; } /** * 取得 finding 等級的人類可讀字串(emoji + 中文標籤),已去除頭尾空白。 * * @param {{ level?: string }} f 單筆審查問題物件。`level` 查無對應時 emoji 留空、標籤回退為原始 level 值。 * @returns {string} 例如 `🔴 嚴重`;無法對應時回退為原始 level 字串(無 emoji)。 * @remarks 內部輔助函式,供 {@link inlineCommentBody} 與 {@link reviewCommentBody} 組裝 comment 內文使用。 */ const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim(); /** * findings 排序比較器:先依嚴重等級(critical < warning < info < 其他),同級再依 location 字串排序。 * * @param {{ level?: string, location?: string }} a 比較項 A。 * @param {{ level?: string, location?: string }} b 比較項 B。 * @returns {number} 負值代表 a 排在 b 之前,正值代表之後,0 代表相等(供 Array.prototype.sort 使用)。 * @remarks 不在 LEVEL_ORDER 內的等級一律視為最低優先(排在最後);location 未定義時以空字串參與比較,因此排序穩定不會丟例外。 */ const bySeverity = (a, b) => { const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length; const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length; if (aLevel !== bLevel) return aLevel - bLevel; return String(a.location || '').localeCompare(String(b.location || '')); }; /** * 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。 * 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。 */ export function parseLocation(location) { if (typeof location !== 'string') return null; const trimmed = location.trim(); if (trimmed.includes(',')) return null; const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/); if (!match) return null; return { file: match[1], line: Number(match[2]) }; } /** 行內 comment 內容:等級/審查員/建議 */ function inlineCommentBody(f) { return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; } /** * 從 finding 取出問題原因描述,依序嘗試多個可能欄位。 * * @param {{ problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f * 單筆審查問題物件;依序取第一個有值(truthy)的欄位。所有欄位皆無值時回退為「未提供問題原因」。 * @returns {string} 問題原因字串。 * @remarks 內部輔助函式,供 {@link reviewCommentBody} 組裝 comment 內文使用,用以容忍不同來源 finding 的欄位命名差異。 */ function problemText(f) { return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因'; } /** * 產生 review comment 內文(嚴重等級/審查員/問題/建議四行)。 * * @param {{ level?: string, role?: string, suggestion?: string, problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f * 單筆審查問題物件。 * @returns {string} 多行 Markdown 字串。 * @remarks 內部輔助函式,供 {@link toReviewComment} 產生批次 review comment 內文使用。比 {@link inlineCommentBody} 多了「問題」一行。 */ function reviewCommentBody(f) { return [ `**嚴重等級**:${levelText(f)}`, `**審查員**:${f.role}`, `**問題**:${problemText(f)}`, `**建議**:${f.suggestion}`, ].join('\n'); } /** * 計算陣列中符合條件的元素數量。 * * @param {Array} findings 待計數的陣列。 * @param {(item: T) => boolean} predicate 判斷函式;回傳 true 的元素計入。 * @returns {number} 符合條件的元素數量。 * @template T * @remarks 內部輔助函式,供 {@link formatFindingsStats} 與 {@link formatFindingsStatsLine} 統計各等級筆數使用。 */ function countBy(findings, predicate) { return findings.filter(predicate).length; } /** * 過濾出新問題(is_new 不等於 false 者)。 * * @param {Array<{ is_new?: boolean }>} findings 審查問題陣列。 * @returns {Array} 新問題子集合。 * @remarks 內部輔助函式。判定採 `is_new !== false`,因此未設定 is_new(undefined)的 finding 也視為新問題;僅明確 `is_new === false` 會被排除。供統計與 review 發布判斷使用。 */ function newFindingsOnly(findings) { return findings.filter(f => f.is_new !== false); } /** * 判斷 finding 等級是否無法歸入 critical/warning/info(無法標示)。 * * @param {{ level?: string }} f 單筆審查問題物件。 * @returns {boolean} 等級不在 LEVEL_ORDER 內時為 true。 * @remarks 內部輔助函式,供統計表的「⚪ 無法標示」欄位計數使用。 */ const isUnclassified = f => !LEVEL_ORDER.includes(f.level); /** * 產生 findings 統計的 Markdown 表格(新問題/舊問題 × 嚴重/警告/建議/無法標示)。 * * @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列; * `is_new === false` 計入舊問題,其餘計入新問題。 * @returns {string} 含表頭、分隔列與兩資料列的 Markdown 表格字串。 * @remarks 供 {@link buildReviewSummary} 組裝 review 統計本文使用。空陣列時仍輸出表格(各欄為 0 筆)。 */ export function formatFindingsStats(findings) { const oldFindings = findings.filter(f => f.is_new === false); const newFindings = newFindingsOnly(findings); const row = (label, items) => `| ${label} | ${countBy(items, f => f.level === 'critical')} 筆 | ${countBy(items, f => f.level === 'warning')} 筆 | ${countBy(items, f => f.level === 'info')} 筆 | ${countBy(items, isUnclassified)} 筆 |`; return [ '| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |', '| --- | --- | --- | --- | --- |', row('新問題', newFindings), row('舊問題', oldFindings), ].join('\n'); } /** * 產生 findings 統計的單行文字摘要(供 log 使用)。 * * @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列; * `is_new === false` 計入舊問題,其餘計入新問題。 * @returns {string} 形如 `新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...` 的單行字串。 * @remarks 供 {@link postFindingsReview} 在 log 輸出統計時呼叫。內容與 {@link formatFindingsStats} 一致,僅格式為單行純文字。 */ export function formatFindingsStatsLine(findings) { const oldFindings = findings.filter(f => f.is_new === false); const newFindings = newFindingsOnly(findings); const row = items => `嚴重${countBy(items, f => f.level === 'critical')} / 警告${countBy(items, f => f.level === 'warning')} / 建議${countBy(items, f => f.level === 'info')} / 無法標示${countBy(items, isUnclassified)}`; return `新: ${row(newFindings)};舊: ${row(oldFindings)}`; } /** * 組裝 review 本文:標題 + findings 統計表 +(選擇性)用量區塊。 * * @param {Array} findings 用於統計的審查問題陣列。 * @param {string} [usageSection=''] 額外附加的用量/token 統計區塊;空字串時不附加。 * @returns {string} review 本文(Markdown)。 * @remarks 內部輔助函式,供 {@link postFindingsReview} 產生整批 review 的 body。 */ function buildReviewSummary(findings, usageSection = '') { const parts = [ '## AI Code Review 統計', '', formatFindingsStats(findings), ]; if (usageSection) parts.push('', usageSection); return parts.join('\n'); } /** * 將 finding 轉為 Gitea review comment 物件(含檔案路徑、內文、行號)。 * * @param {{ location?: string, level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。 * @returns {{ path: string, body: string, new_position: number } | null} * 可定位時回傳 comment 物件;location 無法解析出行號時回傳 null。 * @remarks 內部輔助函式,供 {@link postFindingsReview} 在 map 後以 `filter(Boolean)` 濾除無法定位的項目。 */ function toReviewComment(f) { const loc = parseLocation(f.location); if (!loc) return null; return { path: loc.file, body: reviewCommentBody(f), new_position: loc.line, }; } /** * 發布單一 Gitea review: * - summaryFindings 只用來統計本文數字(含新舊問題) * - commentFindings 用來產生 review comments,並依嚴重等級排序; * 只為新問題加上行內標註,舊問題(is_new === false)僅計入統計、不再重複標註檔案與行數 */ export async function postFindingsReview(findings, deps = {}) { const { postReview = postPullReview, postInline = postPullReviewComment, postIssue = postComment, summaryFindings = findings, commentFindings = findings, usageSection = '', } = deps; const sortedComments = [...commentFindings].sort(bySeverity); const comments = sortedComments.filter(f => f.is_new !== false).map(toReviewComment).filter(Boolean); const body = buildReviewSummary(summaryFindings, usageSection); try { await postReview({ body, comments }); } catch (e) { warn(`整批 review 發布失敗,改用 summary + 逐筆行內 comment: ${e.message}`); try { await postReview({ body, comments: [] }); } catch (summaryErr) { warn(`review summary 發布失敗,改用一般 comment: ${summaryErr.message}`); await postIssue(body); } for (const comment of comments) { try { await postInline({ path: comment.path, line: comment.new_position, body: comment.body }); } catch (commentErr) { warn(`行內 review comment 發布失敗(略過): ${comment.path}:${comment.new_position} error=${commentErr.message}`); } } } ok(`review 發布: summary=${summaryFindings.length} total=${sortedComments.length} commentable=${comments.length}`); line(`review summary 統計: ${formatFindingsStatsLine(summaryFindings)}`); line(`review comments 統計: ${formatFindingsStatsLine(sortedComments)}`); } /** * 寫入 findings.json。 * 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。 */ export function saveFindings(workspace, findings, mirrorDir = null) { const targets = [workspace]; if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir); for (const targetDir of targets) { const fullPath = path.join(targetDir, FINDINGS_PATH); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8'); ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`); } } /** * 發布所有舊問題 comment(一次發布,依等級排序) */ export async function postOldFindingsComment(findings) { const old = findings.filter(f => !f.is_new); if (old.length === 0) { line('無舊問題,跳過'); return; } const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`; await postComment(body); ok(`舊問題 comment 發布 (${old.length} 筆)`); } /** * 發布新問題中非 critical 的 comment(一次發布) */ export async function postNewNonCriticalComment(findings) { const items = findings.filter(f => f.is_new && f.level !== 'critical'); if (items.length === 0) { line('無新的非嚴重問題,跳過'); return; } const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`; await postComment(body); ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`); } /** * 每個新 critical 問題各發一個 comment。 * 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議); * 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。 */ export async function postNewCriticalComments(findings, deps = {}) { const { postInline = postPullReviewComment, postIssue = postComment } = deps; const criticals = findings.filter(f => f.is_new && f.level === 'critical'); if (criticals.length === 0) { line('無新的嚴重問題,跳過'); return; } for (const f of criticals) { const loc = parseLocation(f.location); if (loc) { try { await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) }); ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`); continue; } catch (e) { warn(`行內 comment 發布失敗,改用一般 comment: [${f.role}] ${f.location} error=${e.message}`); } } await postIssue(`## 🚨 嚴重問題\n\n${buildTable([f])}`); ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`); } }