4 Commits
Author SHA1 Message Date
JefferyandClaude Opus 4.8 d8dd5ce92e chore(ai-review 狀態): 記錄 issue #10 誤報排除並回寫待人工 findings
node-actions/template: CI / BUILD (pull_request) Successful in 4s
CI / TEST (Claude) (pull_request) Successful in 27s
CI / TEST (Antigravity) (pull_request) Successful in 50s
CI / TEST (Codex) (pull_request) Successful in 3m2s
- exclusions.json 新增一條誤報(gitrepo.js:276 使用者名稱固定 ai-review-bot,依管理員指示)。
- 新增 findings 檔追蹤 4 條待人工處理問題(步驟編號硬編碼、兩處缺測試、debug 輸出取捨)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:04:23 +08:00
JefferyandClaude Opus 4.8 cc9c8afed7 docs(ai-review): 同步 JSDoc 對 main() 閉包改名的引用
配合 postComment/ensureIssueCreated 改名,更新 gitea.js 與 templates.js
JSDoc 內對兩個閉包的引用名稱。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:04:23 +08:00
JefferyandClaude Opus 4.8 0c108ab7f9 refactor(ai-review): 重新命名留言閉包並抽出診斷輸出長度常數
- postComment → queueOrPostComment:涵蓋建問題模式下可能只暫存不立即發布的語義。
- ensureIssueCreated → createIssueAndFlushBufferedComments:明示建立 issue 並清空暫存留言的完整行為。
- review.js agentFailureDetail 的 500 字上限抽為具名常數 AGENT_DIAGNOSTIC_OUTPUT_LIMIT。
純內部命名與可讀性調整,無外部行為變更。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:04:23 +08:00
JefferyandClaude Opus 4.8 83dd56f37f fix(resolveMergeBase): deepen HEAD 改以目前 HEAD 的 SHA 補抓 PR head 歷史
原策略 fetch 遠端符號 HEAD,會被伺服器解析為遠端預設分支,
只加深預設分支歷史、補不到目前 checkout 的 PR head,淺層時仍可能算不出 merge-base。
改先 rev-parse 取得 HEAD 的 commit SHA,以該 SHA 加深 PR head 側歷史。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:04:23 +08:00
7 changed files with 97 additions and 16 deletions
+11
View File
@@ -1197,5 +1197,16 @@
"endLine": 175, "endLine": 175,
"problem": "新增 `addIssueDependency` API wrapper,但沒有看到測試固定 endpoint 與 payload。這種薄封裝看似簡單,卻很容易在 issue/PR 編號、`index` 欄位或 repo owner/name 組合上出錯。", "problem": "新增 `addIssueDependency` API wrapper,但沒有看到測試固定 endpoint 與 payload。這種薄封裝看似簡單,卻很容易在 issue/PR 編號、`index` 欄位或 repo owner/name 組合上出錯。",
"reason": "Paladin:可排除(重複)。歷史 findings 已指出 addIssueDependency 缺少 endpoint、HTTP method、payload 與相依方向測試,與本條相同。" "reason": "Paladin:可排除(重複)。歷史 findings 已指出 addIssueDependency 缺少 endpoint、HTTP method、payload 與相依方向測試,與本條相同。"
},
{
"addedAt": "2026/07/20 18:00:02",
"prNumber": 6,
"reviewer": "🔮 Mage",
"severity": "嚴重",
"file": "src/lib/gitrepo.js",
"startLine": 276,
"endLine": 276,
"problem": "提供 pushToken 時仍把 Git HTTP 使用者名稱固定為 ai-review-bot;若 PAT 屬於其他帳號,伺服器會以錯誤的帳號/PAT 組合驗證,導致 push 失敗。",
"reason": "誤報(管理員於 issue #10 留言明確指示列為誤報)。Gitea 的 HTTP Basic 認證以密碼欄(token/PAT)判定身分,使用者名稱欄不影響認證結果,故固定為 ai-review-bot 不會造成 push 失敗。"
} }
] ]
@@ -0,0 +1,65 @@
{
"generatedAt": "2026/07/20 18:00:02",
"commitSha": "d354f30c270e99baa9dcfc6aa76510710c0193ca",
"prNumber": null,
"tool": {
"name": "code-review-resolve",
"version": "0.0.8",
"model": "(工具預設)"
},
"findings": [
{
"id": "F001",
"reviewer": "🧰 Leo",
"focus": "maintainability",
"badge": "🧰",
"severity": "警告",
"file": "src/index.js",
"startLine": 257,
"endLine": 307,
"problem": "流程步驟編號同時硬編碼在 log 字串、區段註解、JSDoc、README 與多個函式庫中。插入或調整一個步驟就必須跨大量檔案全面改號,容易讓文件與實際紀錄不一致。",
"suggestion": "程式內改用穩定的語意階段名稱(如 diff、attack、defend、publish),由單一流程定義集中決定顯示順序;JSDoc 以階段名稱互相引用,README 流程圖由同一份階段資料產生或僅在文件層維護展示編號。",
"suggestedCode": "const PHASE = Object.freeze({\n RESOLVE_OLD: '清理舊留言',\n DETECT_TOOL: '偵測工具',\n COLLECT_DIFF: '整理差異',\n ATTACK: '攻擊方審查',\n DEFEND: '防守方裁決',\n});\n\nlog(PHASE.COLLECT_DIFF, 'INF', `變更檔案 ${allFiles.length} 個…`);"
},
{
"id": "F002",
"reviewer": "🧪 Maya",
"focus": "testing",
"badge": "🧪",
"severity": "警告",
"file": "src/lib/gitrepo.js",
"startLine": 252,
"endLine": 289,
"problem": "commitAndPushFindings 的推送行為(有無變更、認證方式、空 commit 防護、是否觸發 CI)缺少測試證明。此為本次變更的核心行為,卻沒有任何測試覆蓋。",
"suggestion": "mock git 命令,斷言:無 staged diff 時回傳 false 且不執行 commit/push;有變更時以認證方式推送到正確 refspec 與分支。注意:原 finding 描述的 pushToken 對比 origin 雙軌邏輯已於重構後移除(現行一律以 token 經 pushWithCredential 認證推送),撰寫測試前需依現行程式碼重新界定情境。",
"suggestedCode": ""
},
{
"id": "F003",
"reviewer": "🗡️ Assassin",
"focus": "security",
"badge": "🗡️",
"severity": "警告",
"file": "src/lib/review.js",
"startLine": 64,
"endLine": 80,
"problem": "agentFailureDetail 於 AI CLI 失敗時會把(經 redactSecrets 盡力遮罩的)stderrstdout 片段寫入 CI log。redactSecrets 屬盡力遮罩,無法可靠辨識 PII、短密碼或私鑰片段;長期保存且多人可讀的 CI log 有洩漏風險。",
"suggestion": "屬安全(避免洩漏)與可除錯性的設計取捨:現行程式碼已於註解明確權衡並選擇「附上遮罩後輸出以利除錯」。是否改為只記錄退出碼/訊號/逾時狀態+隨機診斷 ID(內容級診斷改寫入有存取控制與短保存期的獨立 artifact)需由維護者裁示,故保留現行行為、標為待人工處理。",
"suggestedCode": "if (verbose) {\n parts.push('已啟用除錯;為避免洩漏原始碼、PII 或憑證,CLI 輸出仍不寫入日誌');\n}"
},
{
"id": "F004",
"reviewer": "🧪 Maya",
"focus": "testing",
"badge": "🧪",
"severity": "建議",
"file": "src/lib/gitea.js",
"startLine": 171,
"endLine": 215,
"problem": "addLabelsToIssue 與 addIssueDependency 缺少契約測試驗證 endpoint、HTTP method 與 request body;相依關係方向由 URL 與 body 決定,參數次序寫反時粗略 mock 的主流程測試不易察覺。",
"suggestion": "補 Gitea client 單元測試:labels 為空或缺少時不呼叫 API 並回傳 null;有 labels 時送出正確陣列;相依 API 以 PR 編號置於 URL、追蹤 issue 編號置於 index,並帶入正確 ownerrepo。",
"suggestedCode": ""
}
],
"excluded": []
}
+10 -10
View File
@@ -198,7 +198,7 @@ async function main() {
* 使用情境:只在 `main()` 內部使用,處理工具資訊、diff 摘要、角色登場與 * 使用情境:只在 `main()` 內部使用,處理工具資訊、diff 摘要、角色登場與
* 警告/建議彙整等留言。若 Gitea API 失敗,例外會往上拋出並由主流程頂層 catch 收斂。 * 警告/建議彙整等留言。若 Gitea API 失敗,例外會往上拋出並由主流程頂層 catch 收斂。
*/ */
const postComment = async (body) => { const queueOrPostComment = async (body) => {
if (ctx.createIssue) { if (ctx.createIssue) {
if (issue) return gitea.createCommentOnIssue(ctx, issue.number, body); if (issue) return gitea.createCommentOnIssue(ctx, issue.number, body);
issueBuffer.push(body); issueBuffer.push(body);
@@ -217,7 +217,7 @@ async function main() {
* 空陣列或省略時不掛任何標籤(`gitea.createIssue` 對空陣列不帶 labels 欄位)。 * 空陣列或省略時不掛任何標籤(`gitea.createIssue` 對空陣列不帶 labels 欄位)。
* @returns {Promise<void>} 無回傳值;結果反映在閉包變數 `issue` 與 issue 留言。 * @returns {Promise<void>} 無回傳值;結果反映在閉包變數 `issue` 與 issue 留言。
*/ */
const ensureIssueCreated = async (labelIds = []) => { const createIssueAndFlushBufferedComments = async (labelIds = []) => {
issue = await gitea.createIssue(ctx, { issue = await gitea.createIssue(ctx, {
title: ctx.prTitle || `AI Code ReviewPR #${ctx.prNumber}`, title: ctx.prTitle || `AI Code ReviewPR #${ctx.prNumber}`,
body: templates.issueBody({ prNumber: ctx.prNumber, prBody: ctx.prBody }), body: templates.issueBody({ prNumber: ctx.prNumber, prBody: ctx.prBody }),
@@ -245,7 +245,7 @@ async function main() {
} }
log('步驟3', 'INF', `選用工具:${tool.name}${tool.version})。`); log('步驟3', 'INF', `選用工具:${tool.name}${tool.version})。`);
const runLink = `${ctx.serverUrl}/${ctx.repository}/actions/runs/${ctx.runId}`; const runLink = `${ctx.serverUrl}/${ctx.repository}/actions/runs/${ctx.runId}`;
await postComment( await queueOrPostComment(
templates.toolComment({ templates.toolComment({
toolName: tool.name, toolName: tool.name,
version: tool.version, version: tool.version,
@@ -270,7 +270,7 @@ async function main() {
if (ctx.createIssue) { if (ctx.createIssue) {
log('步驟4', 'INF', '建問題模式且無可審查變更:靜默通過(不建 issue、PR 不留言)。'); log('步驟4', 'INF', '建問題模式且無可審查變更:靜默通過(不建 issue、PR 不留言)。');
} else { } else {
await postComment(templates.nothingToReviewComment(ignoredCount)); await queueOrPostComment(templates.nothingToReviewComment(ignoredCount));
// 已成功產生本回合結果留言(無可審查變更),此時才把舊留言標為過時(本回合留言已排除)。 // 已成功產生本回合結果留言(無可審查變更),此時才把舊留言標為過時(本回合留言已排除)。
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds }); await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
} }
@@ -286,20 +286,20 @@ async function main() {
const diffRows = review.collectDiffRows({ cwd, files, base, gitrepo }); const diffRows = review.collectDiffRows({ cwd, files, base, gitrepo });
await review.fillPurposes({ tool, model: ctx.model, cwd, diffRows }); await review.fillPurposes({ tool, model: ctx.model, cwd, diffRows });
await postComment(templates.diffComment(diffRows, ignoredCount)); await queueOrPostComment(templates.diffComment(diffRows, ignoredCount));
// ── 步驟 5:攻擊方角色登場留言 ───────────────────────────────────────── // ── 步驟 5:攻擊方角色登場留言 ─────────────────────────────────────────
const roles = loadRoles(path.join(ctx.actionPath, 'src', 'prompts', 'roles')); const roles = loadRoles(path.join(ctx.actionPath, 'src', 'prompts', 'roles'));
const attackers = attackersOf(roles); const attackers = attackersOf(roles);
const defenders = defendersOf(roles); const defenders = defendersOf(roles);
log('步驟5', 'INF', `攻擊方 ${attackers.length} 位、防守方 ${defenders.length} 位。`); log('步驟5', 'INF', `攻擊方 ${attackers.length} 位、防守方 ${defenders.length} 位。`);
await postComment(templates.rolesComment({ title: '⚔️ 攻擊方登場', roles: attackers })); await queueOrPostComment(templates.rolesComment({ title: '⚔️ 攻擊方登場', roles: attackers }));
// ── 步驟 6:每個攻擊方一個 sub agent 並行分析,合併問題列表 ──────────── // ── 步驟 6:每個攻擊方一個 sub agent 並行分析,合併問題列表 ────────────
const findings = await review.runAttackers({ tool, model: ctx.model, cwd, attackers, diffRows }); const findings = await review.runAttackers({ tool, model: ctx.model, cwd, attackers, diffRows });
// ── 步驟 7:防守方角色登場留言 ───────────────────────────────────────── // ── 步驟 7:防守方角色登場留言 ─────────────────────────────────────────
await postComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders })); await queueOrPostComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders }));
// ── 步驟 8:防守方裁決 → 排除 → 排序 → 保存 findings ────────────────── // ── 步驟 8:防守方裁決 → 排除 → 排序 → 保存 findings ──────────────────
const { kept, excluded } = await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings }); const { kept, excluded } = await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings });
@@ -335,7 +335,7 @@ async function main() {
} catch (err) { } catch (err) {
log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`); log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`);
} }
await ensureIssueCreated(labelIds); await createIssueAndFlushBufferedComments(labelIds);
} else { } else {
// 無保留問題 → 不建 issue、PR 也不留言(靜默通過,暫存的情境留言捨棄)。 // 無保留問題 → 不建 issue、PR 也不留言(靜默通過,暫存的情境留言捨棄)。
log('建問題', 'INF', '沒有保留的問題:靜默通過(不建 issue、PR 不留言)。'); log('建問題', 'INF', '沒有保留的問題:靜默通過(不建 issue、PR 不留言)。');
@@ -365,13 +365,13 @@ async function main() {
if (ctx.createIssue) { if (ctx.createIssue) {
await review.postOthersToIssue({ ctx, gitea, issueNumber: issue.number, others }); await review.postOthersToIssue({ ctx, gitea, issueNumber: issue.number, others });
} else { } else {
await postComment(templates.othersComment(others)); await queueOrPostComment(templates.othersComment(others));
log('步驟10', 'INF', `警告+建議表格留言已發布(${others.length} 條)。`); log('步驟10', 'INF', `警告+建議表格留言已發布(${others.length} 條)。`);
} }
} }
// ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─ // ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─
// 標籤已於建立 issue 時一次帶入(見上方 selectLabels → ensureIssueCreated),此處不再補掛。 // 標籤已於建立 issue 時一次帶入(見上方 selectLabels → createIssueAndFlushBufferedComments),此處不再補掛。
if (ctx.createIssue && issue) { if (ctx.createIssue && issue) {
await gitea.createIssueComment( await gitea.createIssueComment(
ctx, ctx,
+2 -2
View File
@@ -119,7 +119,7 @@ function createCommentOnIssue(ctx, issueNumber, body) {
* 依 Gitea API 回應而定)。 * 依 Gitea API 回應而定)。
* @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。 * @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
* @remarks 使用情境:AI review 各步驟把審查摘要、角色登場、問題彙整等內容 * @remarks 使用情境:AI review 各步驟把審查摘要、角色登場、問題彙整等內容
* 以一般留言形式張貼到本次 PR 上(`main()` 的 `postComment` 閉包即以本函式實作)。 * 以一般留言形式張貼到本次 PR 上(`main()` 的 `queueOrPostComment` 閉包即以本函式實作)。
*/ */
function createIssueComment(ctx, body) { function createIssueComment(ctx, body) {
return createCommentOnIssue(ctx, ctx.prNumber, body); return createCommentOnIssue(ctx, ctx.prNumber, body);
@@ -156,7 +156,7 @@ function listLabels(ctx) {
* @returns {Promise<object>} 建立成功的 issue 物件(含 `number`、`title`、 * @returns {Promise<object>} 建立成功的 issue 物件(含 `number`、`title`、
* `html_url` 等欄位,依 Gitea API 回應而定)。 * `html_url` 等欄位,依 Gitea API 回應而定)。
* @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。 * @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
* @remarks 使用情境:建問題模式(input: create-issue)下,`main()` 的 `ensureIssueCreated` * @remarks 使用情境:建問題模式(input: create-issue)下,`main()` 的 `createIssueAndFlushBufferedComments`
* 以 PR 標題/描述為 issue 標題與本文,並帶入 `review.selectLabels` 事先挑好的標籤 id * 以 PR 標題/描述為 issue 標題與本文,並帶入 `review.selectLabels` 事先挑好的標籤 id
* 呼叫本函式一次建立追蹤問題的 issue(連同標籤),之後再把審查內容逐條留言到該 issue。 * 呼叫本函式一次建立追蹤問題的 issue(連同標籤),之後再把審查內容逐條留言到該 issue。
*/ */
+4 -1
View File
@@ -133,9 +133,12 @@ function resolveMergeBase(cwd, baseRef) {
// 資料驅動的補抓策略:先以固定深度分批加深 base 與 HEAD(每步後重試 merge-base,成功即回傳); // 資料驅動的補抓策略:先以固定深度分批加深 base 與 HEAD(每步後重試 merge-base,成功即回傳);
// 只有仍失敗且為淺層 repo 時,才把成本最高的 --unshallow(下載完整歷史)當最後手段, // 只有仍失敗且為淺層 repo 時,才把成本最高的 --unshallow(下載完整歷史)當最後手段,
// 避免大型/長壽 repo 只為找共同祖先就無謂拉全史。 // 避免大型/長壽 repo 只為找共同祖先就無謂拉全史。
// 加深 HEAD 側須以「目前 HEAD 的 commit SHA」補抓——遠端符號 `HEAD` 由伺服器解析為
// 遠端預設分支(非目前 checkout 的 PR head),只加深它並不會補到 PR head 的歷史。
const headSha = gitTrim(cwd, 'rev-parse', 'HEAD');
const strategies = [ const strategies = [
['deepen base', 'fetch', '--no-tags', '--deepen=1000', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`], ['deepen base', 'fetch', '--no-tags', '--deepen=1000', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`],
['deepen HEAD', 'fetch', '--no-tags', '--deepen=1000', 'origin', 'HEAD'], ['deepen PR HEAD', 'fetch', '--no-tags', '--deepen=1000', 'origin', headSha],
]; ];
if (gitTrim(cwd, 'rev-parse', '--is-shallow-repository') === 'true') { if (gitTrim(cwd, 'rev-parse', '--is-shallow-repository') === 'true') {
strategies.push(['unshallow', 'fetch', '--no-tags', '--unshallow', 'origin']); strategies.push(['unshallow', 'fetch', '--no-tags', '--unshallow', 'origin']);
+4 -2
View File
@@ -66,13 +66,15 @@ function agentFailureDetail(res) {
// 先截去過長輸入再遮罩,避免對數 MB 的失敗輸出跑整份 O(k×n) 正規掃描; // 先截去過長輸入再遮罩,避免對數 MB 的失敗輸出跑整份 O(k×n) 正規掃描;
// 2000 字上限已足以涵蓋跨界機密樣式,最終仍截為 500 字。 // 2000 字上限已足以涵蓋跨界機密樣式,最終仍截為 500 字。
const INPUT_LIMIT = 2_000; const INPUT_LIMIT = 2_000;
// 每段診斷片段(stderr/stdout)寫入日誌的字元上限,兩段共用同一政策,抽為具名常數避免兩處各寫一個魔術數字。
const AGENT_DIAGNOSTIC_OUTPUT_LIMIT = 500;
// 預設即附上「經 redactSecrets 遮罩+去控制字元+限長」的 stderr 與 stdout 片段——CLI 失敗時 // 預設即附上「經 redactSecrets 遮罩+去控制字元+限長」的 stderr 與 stdout 片段——CLI 失敗時
// 只印 exit code 幾乎無從除錯(見 test-claude 秒失敗案例);且部分 CLI(如 claude-code 的 // 只印 exit code 幾乎無從除錯(見 test-claude 秒失敗案例);且部分 CLI(如 claude-code 的
// -p 模式)會把錯誤寫到 stdout 而非 stderr,故兩者都輸出。redactSecrets 為盡力防線。 // -p 模式)會把錯誤寫到 stdout 而非 stderr,故兩者都輸出。redactSecrets 為盡力防線。
const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT)); const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT));
if (stderr) parts.push(`stderr${stderr.slice(0, 500)}`); if (stderr) parts.push(`stderr${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT)); const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT));
if (stdout) parts.push(`stdout${stdout.slice(0, 500)}`); if (stdout) parts.push(`stdout${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
if (parts.length === 0) { if (parts.length === 0) {
parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)'); parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)');
} }
+1 -1
View File
@@ -302,7 +302,7 @@ function othersComment(findings) {
* @param {string} [params.prBody] - PR 描述原文;nullish 或 trim 後為空時輸出佔位文字。 * @param {string} [params.prBody] - PR 描述原文;nullish 或 trim 後為空時輸出佔位文字。
* @returns {string} 完整 issue 本文 Markdown 字串(含 MARK 隱藏標記)。 * @returns {string} 完整 issue 本文 Markdown 字串(含 MARK 隱藏標記)。
* @remarks * @remarks
* 使用情境:建問題模式下 `main()`src/index.js)的 `ensureIssueCreated` 建立 issue 時, * 使用情境:建問題模式下 `main()`src/index.js)的 `createIssueAndFlushBufferedComments` 建立 issue 時,
* 以「標題=PR 標題、本文=本函式輸出」呼叫 `gitea.createIssue` * 以「標題=PR 標題、本文=本函式輸出」呼叫 `gitea.createIssue`
* 讓 issue 讀者能從本文回溯到觸發審查的 PR,再從下方留言逐條查看問題明細。 * 讓 issue 讀者能從本文回溯到觸發審查的 PR,再從下方留言逐條查看問題明細。
*/ */