feat(ai-code-review): 新增 AI 多角色 code review action(攻防審查、findings 保存、建問題模式)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-17 16:53:27 +08:00
co-authored by Claude Fable 5
parent 6a0573b7c0
commit d08b97bd87
18 changed files with 2884 additions and 19 deletions
+400
View File
@@ -0,0 +1,400 @@
'use strict';
// 固定留言模板:本 action 發到 PR 的留言一律由此產生(繁體中文、UTF-8、表格優先)。
// 隱藏標記:辨識哪些留言是本 action 發的(步驟 8 標註過時時使用)。
const MARK = '<!-- ai-code-review -->';
// 舊留言標註前綴(步驟 8 的降級做法:無 resolve API 時編輯加註)。
const OUTDATED_PREFIX = '> 〔已過時〕本留言屬於較舊的審查回合。\n\n';
// 嚴重等級對應的 emoji 與排序權重。
const SEVERITY_EMOJI = { 嚴重: '🔴', 警告: '🟠', 建議: '🔵' };
const SEVERITY_ORDER = { 嚴重: 0, 警告: 1, 建議: 2 };
// 面向代碼對應的中文標籤。
const FOCUS_LABEL = {
logic: '邏輯',
security: '安全性',
efficiency: '效率',
style: '風格',
testing: '測試',
maintainability: '可維護性',
verdict: '裁決',
};
/**
* 把任意文字整理成可安全放進 Markdown 表格儲存格的單行內容。
*
* 處理順序:nullish 轉空字串 → 逸出管線符號(`|` → `\|`)→ 換行轉 `<br>`
* → 去除頭尾空白 → 空字串以 `—` 佔位。純函式、無副作用,任何輸入
* (含 nullundefined/數字)都不會拋出例外。
*
* @param {*} text - 任意待處理內容;非字串會先以 `String()` 轉型,nullundefined 視為空字串。
* @returns {string} 已逸出、單行化的儲存格內容;若結果為空則回傳 `'—'`。
* @remarks
* 使用情境:審查流程中所有表格型留言的共用防呆——例如步驟 3 的
* `diffComment()` 產生變更摘要表格時,檔名與用途欄位都經本函式處理,
* 避免檔名或 AI 產生的描述含 `|` 或換行而撐破 Markdown 表格。
* 本函式未匯出,僅供模組內部使用。
*/
function cell(text) {
return String(text ?? '')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, '<br>')
.trim() || '—';
}
/**
* 把審查面向代碼轉成「中文(原文)」的顯示字串。
*
* 以模組常數 `FOCUS_LABEL` 查表,支援 logicsecurityefficiency
* styletestingmaintainabilityverdict 七種代碼;查表命中回傳
* 「中文(代碼)」格式,未命中則原樣回傳代碼,falsy 輸入回傳 `—`。
*
* @param {string} focus - 審查面向代碼(例如 `'logic'`、`'security'`);可為 undefined。
* @returns {string} 顯示字串:命中時如 `'邏輯(logic'`;未命中時原樣回傳 `focus`falsy 時回傳 `'—'`。
* @remarks
* 使用情境:審查流程步驟 4/6 的角色登場留言——`rolesComment()`
* 產生「角色|面向|個性」表格時,以本函式把每位審查員
* (攻擊方/防守方)的 focus 代碼轉成中英並列的面向欄位內容。
* 本函式未匯出,僅供模組內部使用。
*/
function focusLabel(focus) {
const label = FOCUS_LABEL[focus];
return label ? `${label}${focus}` : focus || '—';
}
/**
* 產生審查流程步驟 2 的「審查工具」PR 留言內容。
*
* 留言以隱藏標記 `MARK` 開頭,包含工具資訊表格(工具/版本/模型/
* 審查 commitRun Job 連結)與一張 mermaid 流程圖,說明整條審查管線
* (整理 git diff → 攻擊方找問題 → 防守方裁決 → 保存 findings → 留言到 PR)。
*
* @param {Object} params - 工具資訊(解構參數)。
* @param {string} params.toolName - 審查工具名稱,直接以行內程式碼呈現(不經 cell 逸出)。
* @param {string} params.version - 工具版本;經 cell() 防呆。
* @param {string} [params.model] - 使用的 AI 模型;falsy 時顯示「(工具預設)」。
* @param {string} params.sha - 本回合審查的 commit SHA;經 cell() 防呆。
* @param {string|number} params.runNumber - CI run 編號,作為連結文字;經 cell() 防呆。
* @param {string} params.runLink - CI run 的網址,直接內插為 Markdown 連結目標。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記,結尾帶換行)。
* @remarks
* 使用情境:審查流程步驟 2——每回合審查開始時,先把工具身分與
* 管線流程圖留言到 PR,讓開發者知道這回合由哪個版本/模型執行;
* 留言開頭的 MARK 讓步驟 8 能辨識並將舊回合留言標註為過時。
*/
function toolComment({ toolName, version, model, sha, runNumber, runLink }) {
return `${MARK}
## 🤖 AI Code Review|審查工具
| 項目 | 內容 |
| --- | --- |
| 工具 | \`${toolName}\` |
| 版本 | \`${cell(version)}\` |
| 模型 | ${model ? `\`${cell(model)}\`` : '(工具預設)'} |
| 審查 commit | \`${cell(sha)}\` |
| Run Job | [#${cell(runNumber)}](${runLink}) |
\`\`\`mermaid
flowchart LR
A[整理 git diff] --> B[⚔️ 攻擊方找問題]
B --> C[🛡️ 防守方裁決]
C --> D[保存 findings]
D --> E[留言到 PR]
\`\`\`
`;
}
/**
* 產生審查流程步驟 3 的「變更摘要(送審 git diff)」PR 留言內容。
*
* 以四欄表格(檔案/用途/git diff 長度/最後更新時間)列出本回合
* 送審的每個檔案;diff 過長被截斷送審的檔案會加註「(過長截斷送審)」,
* 無任何檔案時補上「(無)」佔位列。結尾以引言統計納入審查的檔案數,
* 並在有排除檔案時註明 `.reviewignore` 排除數量。
*
* @param {Array<Object>} rows - 送審檔案清單,每筆一列。
* @param {string} rows[].file - 檔案路徑;經 cell() 防呆後以行內程式碼呈現。
* @param {string} rows[].purpose - 檔案用途說明;經 cell() 防呆。
* @param {number} rows[].lines - 該檔 git diff 行數。
* @param {number} rows[].chars - 該檔 git diff 字元數。
* @param {boolean} [rows[].truncated] - 是否因 diff 過長而截斷送審。
* @param {string} rows[].lastUpdated - 檔案最後更新時間;經 cell() 防呆。
* @param {number} ignoredCount - 依 `.reviewignore` 排除的檔案數;大於 0 才顯示排除註記。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:審查流程步驟 3——整理完 git diff 後,把「哪些檔案、多長、
* 是否截斷、哪些被 .reviewignore 排除」留言到 PR,讓開發者確認送審範圍
* 與 AI 實際看到的內容一致。
*/
function diffComment(rows, ignoredCount) {
const lines = [
MARK,
'## 📋 變更摘要(送審 git diff',
'',
'| 檔案 | 用途 | git diff 長度 | 最後更新時間 |',
'| --- | --- | --- | --- |',
];
for (const row of rows) {
const length = `${row.lines} 行/${row.chars} 字元${row.truncated ? '(過長截斷送審)' : ''}`;
lines.push(`| \`${cell(row.file)}\` | ${cell(row.purpose)} | ${length} | ${cell(row.lastUpdated)} |`);
}
if (rows.length === 0) {
lines.push('| (無) | — | — | — |');
}
lines.push('');
lines.push(`> 共 ${rows.length} 個檔案納入審查${ignoredCount > 0 ? `;另有 ${ignoredCount} 個檔案依 \`.reviewignore\` 排除` : ''}`);
return lines.join('\n');
}
/**
* 產生審查流程步驟 4/6 共用的「角色登場」PR 留言內容。
*
* 以三欄表格(角色/面向/個性)列出本回合登場的審查員;
* 攻擊方(步驟 4)與防守方(步驟 6)共用本模板,僅標題不同。
* 面向欄位經 focusLabel() 轉成「中文(原文)」並列格式。
*
* @param {Object} params - 留言內容(解構參數)。
* @param {string} params.title - 留言標題(接在 `## ` 之後),由呼叫端決定攻擊方或防守方文案;不經逸出。
* @param {Array<Object>} params.roles - 登場角色清單,每筆一列。
* @param {Object} params.roles[].meta - 角色的中繼資料。
* @param {string} [params.roles[].meta.badge] - 角色徽章(通常為 emoji);缺省時以空字串呈現。
* @param {string} params.roles[].meta.name - 角色名稱,粗體呈現;經 cell() 防呆。
* @param {string} params.roles[].meta.focus - 審查面向代碼(如 `'logic'`);經 focusLabel() 轉為「中文(原文)」。
* @param {string} params.roles[].meta.personality - 角色個性描述;經 cell() 防呆。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:審查流程步驟 4(攻擊方登場)與步驟 6(防守方登場)——
* 在各階段開始審查前,把該回合參與的審查員角色、負責面向與個性
* 留言到 PR,讓開發者理解後續 findings 是由哪些視角產出的。
*/
function rolesComment({ title, roles }) {
const lines = [
MARK,
`## ${title}`,
'',
'| 角色 | 面向 | 個性 |',
'| --- | --- | --- |',
];
for (const role of roles) {
lines.push(
`| ${role.meta.badge || ''} **${cell(role.meta.name)}** | ${cell(focusLabel(role.meta.focus))} | ${cell(role.meta.personality)} |`,
);
}
return lines.join('\n');
}
/**
* 產生審查流程步驟 9 的「單條嚴重問題」留言內容(掛在程式碼行上)。
*
* 留言含嚴重度 emoji 標題(審查員具名)、可選的位置與程式碼片段、
* 「問題」「修改建議」段落、可選的「建議寫法」程式碼區塊,並以
* 「開發者可直接回覆本留言討論或說明取捨」收尾。
*
* @param {Object} finding - 單條審查發現。
* @param {string} finding.severity - 嚴重等級(嚴重/警告/建議);決定標題 emoji,未知等級 fallback 為 🔴。
* @param {string} [finding.badge] - 審查員徽章(通常為 emoji);缺省時省略。
* @param {string} finding.reviewer - 審查員名稱。
* @param {string} finding.file - 問題所在檔案路徑;僅 withLocation 為 true 時輸出。
* @param {number} finding.startLine - 問題起始行號;僅 withLocation 為 true 時輸出。
* @param {number} finding.endLine - 問題結束行號;僅 withLocation 為 true 時輸出。
* @param {string} [finding.problem] - 問題描述;缺省以 `—` 佔位。
* @param {string} [finding.suggestion] - 修改建議;缺省以 `—` 佔位。
* @param {string} [finding.suggestedCode] - 建議寫法程式碼;有值才輸出「建議寫法」區塊。
* @param {string} [snippet] - 問題所在的原始程式碼片段;有值才輸出程式碼圍欄。
* @param {Object} [options] - 選項(解構參數,預設空物件)。
* @param {boolean} [options.withLocation=false] - 是否在內文標明「位置」(檔案與起訖行);掛行留言本身已定位時可省略。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:審查流程步驟 9——防守方裁決後保留的每條「嚴重」finding,
* 逐條以 inline review comment 掛在 PR 對應程式碼行上;若平台不支援
* 掛行而降級為一般留言時,改以 `withLocation: true` 在內文標明位置。
*/
function severeCommentBody(finding, snippet, { withLocation = false } = {}) {
const emoji = SEVERITY_EMOJI[finding.severity] || '🔴';
const parts = [MARK, `### ${emoji} ${finding.severity}${finding.badge || ''} ${finding.reviewer}`, ''];
if (withLocation) {
parts.push(`**位置**\`${finding.file}\`${finding.startLine}${finding.endLine}`, '');
}
if (snippet) {
parts.push('```', snippet, '```', '');
}
parts.push('**問題**', '', finding.problem || '—', '');
parts.push('**修改建議**', '', finding.suggestion || '—', '');
if (finding.suggestedCode) {
parts.push('**建議寫法**', '', '```', finding.suggestedCode, '```', '');
}
parts.push('> 開發者可直接回覆本留言討論或說明取捨。');
return parts.join('\n');
}
/**
* 產生審查流程步驟 9 的「嚴重問題 review 總覽」內容。
*
* 作為 PR review 的整體 body:標題標明嚴重問題總數,並說明各條問題
* 已逐條掛在對應程式碼行上(由 severeCommentBody() 產生的 inline
* comment),請開發者逐一處理或回覆說明。
*
* @param {number} count - 本回合嚴重 findings 的總條數,直接內插進標題。
* @returns {string} review 總覽 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:審查流程步驟 9——把所有嚴重 findings 以單一 PR review
* 送出時,本函式產生 review 的 body 總覽,搭配每條 finding 各自的
* inline commentsevereCommentBody),讓開發者先看到總數再逐條處理。
*/
function severeReviewBody(count) {
return `${MARK}
## 🔴 嚴重問題(共 ${count} 條)
以下嚴重問題已逐條掛在對應程式碼行上,請逐一處理或回覆說明。`;
}
/**
* 產生審查流程步驟 10 的「其他問題(警告+建議)彙整」PR 留言內容。
*
* 非嚴重的 findings 不逐條掛在程式碼行上,改以六欄表格
* (等級/審查員/檔案名稱/問題起訖行數/問題描述/修改建議)
* 集中呈現;等級欄依 SEVERITY_EMOJI 顯示 emoji(警告 🟠、建議 🔵,
* 未知等級 fallback 為 🔵),描述與建議經 cell() 防呆避免撐破表格。
*
* @param {Array<Object>} findings - 警告+建議等級的審查發現清單,每筆一列。
* @param {string} findings[].severity - 嚴重等級(警告/建議);決定等級欄 emoji。
* @param {string} [findings[].badge] - 審查員徽章(通常為 emoji);缺省時省略。
* @param {string} findings[].reviewer - 審查員名稱;經 cell() 防呆。
* @param {string} findings[].file - 問題所在檔案路徑;經 cell() 防呆後以行內程式碼呈現。
* @param {number} findings[].startLine - 問題起始行號。
* @param {number} findings[].endLine - 問題結束行號。
* @param {string} findings[].problem - 問題描述;經 cell() 防呆。
* @param {string} findings[].suggestion - 修改建議;經 cell() 防呆。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:審查流程步驟 10——防守方裁決後留下的「警告」與「建議」
* 等級 findings,不像嚴重問題逐條掛行(步驟 9),而是彙整成單一
* 表格留言發到 PR,讓開發者一覽非阻擋性的改善事項。
*/
function othersComment(findings) {
const lines = [
MARK,
`## 🟠 其他問題(警告+建議,共 ${findings.length} 條)`,
'',
'| 等級 | 審查員 | 檔案名稱 | 問題起訖行數 | 問題描述 | 修改建議 |',
'| --- | --- | --- | --- | --- | --- |',
];
for (const finding of findings) {
const emoji = SEVERITY_EMOJI[finding.severity] || '🔵';
lines.push(
`| ${emoji} ${finding.severity} | ${finding.badge || ''} ${cell(finding.reviewer)} | \`${cell(finding.file)}\` | ${finding.startLine}${finding.endLine} | ${cell(finding.problem)} | ${cell(finding.suggestion)} |`,
);
}
return lines.join('\n');
}
/**
* 產生建問題模式(input: create-issue)新 issue 的本文:
* 以 PR 描述為主體(缺省時以「(PR 無描述)」佔位),
* 尾端附水平線與追溯引言,標明本 issue 由 AI Code Review 依哪個 PR 自動建立、
* 問題明細見 issue 下方留言。
*
* @param {Object} params - 解構參數。
* @param {number} params.prNumber - 來源 PR 編號;內插到追溯引言(`PR #N`)。
* @param {string} [params.prBody] - PR 描述原文;nullish 或 trim 後為空時輸出佔位文字。
* @returns {string} 完整 issue 本文 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:建問題模式下 `createIssueWithFindings`src/lib/review.js)建立 issue 時,
* 以「標題=PR 標題、本文=本函式輸出」呼叫 `gitea.createIssue`
* 讓 issue 讀者能從本文回溯到觸發審查的 PR,再從下方留言逐條查看問題明細。
*/
function issueBody({ prNumber, prBody }) {
const body = (prBody || '').trim();
return `${MARK}
${body || 'PR 無描述)'}
---
> 本問題由 AI Code Review 依 PR #${prNumber} 的審查結果自動建立,問題明細見下方留言。`;
}
/**
* 產生建問題模式(input: create-issue)下單條 finding 的 issue 留言內容。
*
* 固定模板:嚴重等級 emoji 標題(審查員具名)→ 位置(檔案與起訖行數,一律輸出)
* → 問題描述 → 修改建議 → 可選的「建議寫法」程式碼區塊。
* issue 留言無法掛在程式碼行上,故位置固定以內文標明。
*
* @param {Object} finding - 單條審查發現。
* @param {string} finding.severity - 嚴重等級(嚴重/警告/建議);決定標題 emoji,未知等級 fallback 為 🔵。
* @param {string} [finding.badge] - 審查員徽章(通常為 emoji);缺省時省略。
* @param {string} finding.reviewer - 審查員名稱。
* @param {string} finding.file - 問題所在檔案路徑(repo 相對路徑)。
* @param {number} finding.startLine - 問題起始行號(新版檔案行號)。
* @param {number} finding.endLine - 問題結束行號(新版檔案行號)。
* @param {string} [finding.problem] - 問題描述;缺省以 `—` 佔位。
* @param {string} [finding.suggestion] - 修改建議;缺省以 `—` 佔位。
* @param {string} [finding.suggestedCode] - 建議寫法程式碼;有值才輸出「建議寫法」區塊。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:建問題模式下 `createIssueWithFindings`src/lib/review.js)建立 issue 後,
* 把保留的 findings 依「檔案路徑→嚴重等級→起始行」排序,逐條以本函式產生留言內容、
* 經 `gitea.createCommentOnIssue` 發布到新 issue 上,作為問題明細的追蹤紀錄。
*/
function issueFindingComment(finding) {
const emoji = SEVERITY_EMOJI[finding.severity] || '🔵';
const parts = [
MARK,
`### ${emoji} ${finding.severity}${finding.badge || ''} ${finding.reviewer}`,
'',
`**位置**\`${finding.file}\`${finding.startLine}${finding.endLine}`,
'',
'**問題描述**',
'',
finding.problem || '—',
'',
'**修改建議**',
'',
finding.suggestion || '—',
];
if (finding.suggestedCode) {
parts.push('', '**建議寫法**', '', '```', finding.suggestedCode, '```');
}
return parts.join('\n');
}
/**
* 產生「無可審查變更」時的 PR 留言內容。
*
* 當 git diff 套用 `.reviewignore` 過濾後沒有任何檔案需要送審時,
* 以本留言取代正常的變更摘要(沿用相同標題「📋 變更摘要」),
* 說明本次 PR 沒有可審查的變更並宣告視為審查通過;
* 有檔案被排除時加註排除數量。
*
* @param {number} ignoredCount - 依 `.reviewignore` 排除的檔案數;大於 0 才顯示「(N 個檔案被排除)」註記。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:審查流程步驟 3 的替代路徑——整理 git diff 時發現
* 過濾後送審清單為空(例如整包變更都被 .reviewignore 排除),
* 直接以本留言告知開發者本回合視為審查通過,不再進入
* 攻擊方/防守方審查階段。
*/
function nothingToReviewComment(ignoredCount) {
return `${MARK}
## 📋 變更摘要(送審 git diff)
本次 PR 套用 \`.reviewignore\` 後**沒有可審查的變更**${ignoredCount > 0 ? `${ignoredCount} 個檔案被排除)` : ''},視為審查通過。`;
}
module.exports = {
MARK,
OUTDATED_PREFIX,
SEVERITY_EMOJI,
SEVERITY_ORDER,
toolComment,
diffComment,
rolesComment,
severeCommentBody,
severeReviewBody,
othersComment,
issueBody,
issueFindingComment,
nothingToReviewComment,
};