Files
ai-code-review/app/comments.js
T

326 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'];
// 預先把等級對應到排序索引,bySeverity 排序時直接 O(1) 取值,省去每次比較的 includes + indexOf 掃描。
const LEVEL_RANK = new Map(LEVEL_ORDER.map((level, index) => [level, index]));
/**
* 將單一 finding 格式化為 Markdown 表格的一列(等級|審查員|位置|建議)。
*
* @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f
* 單筆審查問題物件。`level` 若不在 critical/warning/info 之內,emoji 留空、標籤回退為原始 level 值;
* `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 時回傳空字串(已防呆,不會拋例外)。
* @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串;`f` 為空值時回傳空字串。
* @remarks 內部輔助函式,供 {@link buildTable} 逐列組裝表格使用,本身不含換行。
*/
function findingRow(f) {
if (!f) return '';
return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`;
}
/**
* 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。
*
* @param {Array<object>} 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_RANK.has(a.level) ? LEVEL_RANK.get(a.level) : LEVEL_ORDER.length;
const bLevel = LEVEL_RANK.has(b.level) ? LEVEL_RANK.get(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<T>} 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<object>} 新問題子集合。
* @remarks 內部輔助函式。判定採 `is_new !== false`,因此未設定 is_newundefined)的 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<object>} 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}`);
}
}