391 lines
21 KiB
JavaScript
391 lines
21 KiB
JavaScript
import fs from 'fs';
|
||
import path from 'path';
|
||
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
|
||
import { FINDINGS_PATH } from './config.js';
|
||
import { buildFindingsWrapper } from './json.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}。
|
||
* 注意:本參數必須是陣列,傳入 null/undefined 會在 `.map` 呼叫時拋出 TypeError(未防呆,需人工確認是否要補強)。
|
||
* @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。
|
||
* @remarks 內部輔助函式,供 {@link postOldFindingsComment}、{@link postNewNonCriticalComment}、
|
||
* {@link postNewCriticalComments} 組裝 comment 內文使用。
|
||
* 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。
|
||
*/
|
||
function buildTable(findings) {
|
||
const list = Array.isArray(findings) ? findings : [];
|
||
const rows = list.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 標註使用。
|
||
*
|
||
* @param {string} location finding 的位置字串。支援 `"file:19"`(單行)與 `"file:70-82"`(範圍,僅取起始行 19/70);
|
||
* 若包含逗號(代表對應多個檔案)、非字串、或無法比對出行號,一律視為無法定位。
|
||
* @returns {{ file: string, line: number } | null}
|
||
* 可解析時回傳 `{ file, line }`(line 為正整數起始行);`location` 非字串、含逗號、格式不符、
|
||
* 或行號非正整數時回傳 `null`。
|
||
* @remarks 供 {@link toReviewComment} 與 {@link postNewCriticalComments} 判斷 finding 是否能標註到
|
||
* diff 中的具體檔案行號;回傳 `null` 時呼叫端會降級為一般(非行內)comment。
|
||
* 使用情境:任何要把 finding 轉成 Gitea 行內 review comment 前,都應先呼叫本函式確認可定位。
|
||
*/
|
||
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;
|
||
const line = Number(match[2]);
|
||
return line > 0 ? { file: match[1], line } : null;
|
||
}
|
||
|
||
/**
|
||
* 產生單一 finding 的行內(inline)review comment 內文:等級/審查員/建議三行。
|
||
*
|
||
* @param {{ level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。
|
||
* `role`、`suggestion` 未定義時會直接輸出 `undefined` 字樣(未做防呆轉換)。
|
||
* @returns {string} 三行 Markdown 字串,以 `\n` 連接。
|
||
* @remarks 內部輔助函式,僅供 {@link postNewCriticalComments} 在成功解析出行號({@link parseLocation}
|
||
* 回傳非 null)時,組裝要標註到具體檔案行號的行內 comment 使用;相較 {@link reviewCommentBody}
|
||
* 少了「問題」一行,因為行內位置本身已能定位問題所在。
|
||
* 使用情境:僅用於新(`is_new` 為 truthy)且等級為 `critical` 的 finding,且該 finding 的
|
||
* `location` 能被解析出具體行號時。
|
||
*/
|
||
function inlineCommentBody(f) {
|
||
return `**等級**:${levelText(f)}\n**審查員**:${f?.role || 'AI Review'}\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 || 'AI Review'}`,
|
||
`**問題**:${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_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<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,必要時降級成 summary review 或一般 comment。
|
||
*
|
||
* @param {Array<object>} findings 審查 findings。
|
||
* @param {object} [deps={}] 可注入的相依物件。
|
||
* @returns {Promise<void>} 無回傳值。
|
||
*/
|
||
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 寫入新版 wrapper 格式的 `findings.json`(同步阻塞 I/O)。
|
||
*
|
||
* @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。
|
||
* @param {Array<object>} findings 要寫入的 findings 陣列;會包成包含 `generatedAt`/`commitSha`/
|
||
* `prNumber`/`tool`/`findings`/`excluded` 的 wrapper,再以 2 空白縮排 JSON 序列化並補換行。
|
||
* @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用);
|
||
* 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。
|
||
* @returns {void} 無回傳值;成功時每個目標各記錄一行 log。
|
||
* @remarks 每個目標寫入前皆會以 `fs.mkdirSync(..., { recursive: true })` 建立必要的父目錄。
|
||
* 本函式為同步阻塞呼叫,且**未做例外防護**——`fs.mkdirSync`/`fs.writeFileSync` 拋出的例外
|
||
* (例如權限不足、磁碟已滿)會直接向呼叫端傳播,需人工確認呼叫端是否需要額外 try/catch。
|
||
* 使用情境:每輪 AI Code Review 完成、findings 已定案後呼叫一次,將結果落地成 JSON 檔;
|
||
* 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。
|
||
*/
|
||
export function saveFindings(workspace, findings, mirrorDir = null) {
|
||
const wrapper = buildFindingsWrapper(findings, []);
|
||
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(wrapper, null, 2) + '\n', 'utf8');
|
||
ok(`findings 寫入: ${fullPath} (${wrapper.findings.length} 筆)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。
|
||
*
|
||
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題。
|
||
* @returns {Promise<void>} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出。
|
||
* 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。
|
||
*/
|
||
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(一次性發布一則一般 comment)。
|
||
*
|
||
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||
* 以 `f.is_new && f.level !== 'critical'` 篩選——`is_new` 須為 truthy(例如 `true`)才算新問題,
|
||
* `undefined`/`false` 皆會被排除。
|
||
* `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。
|
||
* @returns {Promise<void>} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||
* @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。
|
||
* 使用情境:每輪 AI Code Review 中,把「明確標記為新(`is_new === true`)且非嚴重」的問題
|
||
* 統一彙總成一則 comment 通知,嚴重問題另由 {@link postNewCriticalComments} 逐筆單獨發布。
|
||
*/
|
||
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 標註問題檔案與行數
|
||
* (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。
|
||
*
|
||
* @param {Array<{ is_new?: boolean, level?: string, location?: string, role?: string, suggestion?: string }>} findings
|
||
* 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題。
|
||
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。
|
||
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
|
||
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
|
||
* @returns {Promise<void>} 無回傳值;`criticals.length === 0` 時直接 return。
|
||
* @remarks 每筆 critical finding 依序處理:`location` 能解析出行號且 `postInline` 成功時只發行內
|
||
* comment;否則(無法解析,或 `postInline` 失敗且已被捕捉記錄 warn)改用 `postIssue` 發一般 comment。
|
||
* **注意**:`postIssue` 呼叫未包在 try/catch 中,若其拋出例外會中斷整個迴圈,導致排在後面的
|
||
* critical findings 不會被處理,此為需人工確認的行為,是否要補強視情況而定。
|
||
* 使用情境:每輪 AI Code Review 中,把「明確標記為新且等級為 critical」的問題逐筆單獨標註到
|
||
* PR 對應的檔案行號,讓審查者能直接在 diff 上看到問題。
|
||
*/
|
||
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}`);
|
||
}
|
||
}
|