Files
ai-code-review/src/lib/templates.js
T

426 lines
21 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.
'use strict';
// 固定留言模板:本 action 發到 PR 的留言一律由此產生(繁體中文、UTF-8、表格優先)。
// 隱藏標記:辨識哪些留言是本 action 發的(步驟 2 標註過時時使用)。
const MARK = '<!-- ai-code-review -->';
// 舊留言標註前綴(步驟 2 的降級做法:無 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
* 使用情境:審查流程中所有表格型留言的共用防呆——例如步驟 4 的
* `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
* 使用情境:審查流程步驟 5/7 的角色登場留言——`rolesComment()`
* 產生「角色|面向|個性」表格時,以本函式把每位審查員
* (攻擊方/防守方)的 focus 代碼轉成中英並列的面向欄位內容。
* 本函式未匯出,僅供模組內部使用。
*/
function focusLabel(focus) {
const label = FOCUS_LABEL[focus];
return label ? `${label}${focus}` : focus || '—';
}
/**
* 產生審查流程步驟 3 的「審查工具」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
* 使用情境:審查流程步驟 3——每回合審查開始時,先把工具身分與
* 管線流程圖留言到 PR,讓開發者知道這回合由哪個版本/模型執行;
* 留言開頭的 MARK 讓步驟 2 能辨識並將舊回合留言標註為過時。
*/
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]
\`\`\`
`;
}
/**
* 產生審查流程步驟 4 的「變更摘要(送審 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
* 使用情境:審查流程步驟 4——整理完 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');
}
/**
* 產生審查流程步驟 5/7 共用的「角色登場」PR 留言內容。
*
* 以三欄表格(角色/面向/個性)列出本回合登場的審查員;
* 攻擊方(步驟 5)與防守方(步驟 7)共用本模板,僅標題不同。
* 面向欄位經 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
* 使用情境:審查流程步驟 5(攻擊方登場)與步驟 7(防守方登場)——
* 在各階段開始審查前,把該回合參與的審查員角色、負責面向與個性
* 留言到 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
* 使用情境:建問題模式下 `main()`src/index.js)的 `createIssueAndFlushBufferedComments` 建立 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
* 使用情境:建問題模式下 `review.postSevereToIssue` 與 `review.postOthersToIssue`
* 把每條 finding 以本函式產生留言內容、經 `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
* 使用情境:審查流程步驟 4 的替代路徑——整理 git diff 時發現
* 過濾後送審清單為空(例如整包變更都被 .reviewignore 排除),
* 直接以本留言告知開發者本回合視為審查通過,不再進入
* 攻擊方/防守方審查階段。
*/
function nothingToReviewComment(ignoredCount) {
return `${MARK}
## 📋 變更摘要(送審 git diff)
本次 PR 套用 \`.reviewignore\` 後**沒有可審查的變更**${ignoredCount > 0 ? `${ignoredCount} 個檔案被排除)` : ''},視為審查通過。`;
}
/**
* 產生建問題模式(input: create-issue)下,回貼到「PR」的追蹤問題連結留言。
*
* 建問題模式把審查內容全部發到 issue、不留在 PR;本留言是 PR 上唯一的一則審查留言,
* 提供 issue 連結與問題數量統計,讓 PR 讀者一眼看到「本次審查結果在哪個 issue」。
*
* @param {Object} params - 解構參數。
* @param {number} params.issueNumber - 追蹤問題的 issue 編號;內插為 Markdown 連結文字。
* @param {string} params.issueUrl - 追蹤問題的 issue 網址;作為 Markdown 連結目標。
* @param {number} params.severeCount - 嚴重問題條數,顯示在統計。
* @param {number} params.otherCount - 警告+建議問題條數,顯示在統計。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:建問題模式下 `main()`src/index.js)在 issue 建立並寫入全部審查內容後,
* 以本函式對 PR 留一則連結留言,達成「問題關聯回 PR」;issue 內文另以
* {@link issueBody} 反向引用 `PR #N`,形成雙向交叉連結。
*/
function prIssueLinkComment({ issueNumber, issueUrl, severeCount, otherCount }) {
return `${MARK}
## 🔍 AI Code Review|已建立追蹤問題
本次審查結果已彙整到 issue [#${issueNumber}](${issueUrl})(🔴 嚴重 ${severeCount} 條、🟠🔵 警告+建議 ${otherCount} 條),請至該問題追蹤與討論。`;
}
module.exports = {
MARK,
OUTDATED_PREFIX,
SEVERITY_EMOJI,
SEVERITY_ORDER,
toolComment,
diffComment,
rolesComment,
severeCommentBody,
severeReviewBody,
othersComment,
issueBody,
issueFindingComment,
nothingToReviewComment,
prIssueLinkComment,
};