feat(review 發布): 將 AI 審查結果集中到同一筆 Pull Review

This commit is contained in:
2026-06-22 09:41:09 +00:00
parent 1b3c5a7aec
commit 1477822f4b
3 changed files with 88 additions and 21 deletions
+65 -1
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { postComment, postPullReviewComment } from './gitea.js';
import { createPullReview, postComment, postPullReviewComment } from './gitea.js';
import { FINDINGS_PATH } from './config.js';
import { ok, line, warn } from './log.js';
@@ -36,6 +36,70 @@ function inlineCommentBody(f) {
return `**等級**${levelText(f)}\n**審查員**${f.role}\n**建議**${f.suggestion}`;
}
function reviewSection(title, findings) {
if (findings.length === 0) return '';
return `${title}\n\n${buildTable(findings)}`;
}
function findingGroupLabel(f) {
if (!f.is_new) return '舊有未解決問題';
if (f.level === 'critical') return '新嚴重問題';
return '新發現問題';
}
export function buildReviewPayload(intro, findings, { forceBodyFindings = false } = {}) {
const inlineComments = [];
const bodyFindings = [];
for (const f of findings) {
const loc = forceBodyFindings ? null : parseLocation(f.location);
if (loc) {
inlineComments.push({
path: loc.file,
body: `**分類**${findingGroupLabel(f)}\n${inlineCommentBody(f)}`,
new_position: loc.line,
});
} else {
bodyFindings.push(f);
}
}
const bodyOld = bodyFindings.filter(f => !f.is_new);
const bodyNewNonCritical = bodyFindings.filter(f => f.is_new && f.level !== 'critical');
const bodyNewCritical = bodyFindings.filter(f => f.is_new && f.level === 'critical');
const sections = [
intro,
reviewSection(`## 📋 無法行內標註的舊有未解決問題(${bodyOld.length} 筆)`, bodyOld),
reviewSection(`## 🔍 無法行內標註的新發現問題(${bodyNewNonCritical.length} 筆)`, bodyNewNonCritical),
reviewSection(`## 🚨 無法行內標註的新嚴重問題(${bodyNewCritical.length} 筆)`, bodyNewCritical),
].filter(Boolean);
if (inlineComments.length > 0) {
sections.push(`## 💬 行內標註問題(${inlineComments.length} 筆)\n\n詳見本 review 底下的行內 comments。`);
}
return {
body: sections.join('\n\n'),
comments: inlineComments,
};
}
export async function postFindingsReview(intro, findings, deps = {}) {
const { postReview = createPullReview } = deps;
const payload = buildReviewPayload(intro, findings);
try {
await postReview(payload);
ok(`Review 發布成功 (body findings=${findings.length - payload.comments.length} inline=${payload.comments.length})`);
} catch (e) {
if (payload.comments.length === 0) throw e;
warn(`Review 行內 comment 批次發布失敗,改將所有問題放入同一筆 Review body: ${e.message}`);
const fallbackPayload = buildReviewPayload(intro, findings, { forceBodyFindings: true });
await postReview(fallbackPayload);
ok(`Review 發布成功 (body findings=${findings.length} inline=0)`);
}
}
/**
* 寫入 findings.json。
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
+17 -7
View File
@@ -119,20 +119,30 @@ export async function postComment(body) {
}
/**
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。
* 建立一筆 Pull Review。body 會成為 review 主內容,comments 會成為同一筆 review 底下的行內 comments
*/
export async function postPullReviewComment({ path: filePath, line, body }) {
export async function createPullReview({ body, comments = [], event = 'COMMENT' }) {
const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
{
commit_id: PR_HEAD_SHA || undefined,
event: 'COMMENT',
body: '',
comments: [{ path: filePath, body, new_position: line }],
event,
body,
comments,
},
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
);
return resp.data;
}
/**
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。
*/
export async function postPullReviewComment({ path: filePath, line, body }) {
return createPullReview({
body: '',
comments: [{ path: filePath, body, new_position: line }],
});
}
+6 -13
View File
@@ -1,9 +1,9 @@
import path from 'path';
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { loadRoles, getRoleIntro } from './roles.js';
import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js';
import { getPRDiff, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js';
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { saveFindings, postFindingsReview } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
import { runPreflight } from './preflight.js';
@@ -63,13 +63,8 @@ async function main() {
process.exit(0);
}
try {
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
await postComment(intro);
ok('角色介紹 comment 發布成功');
} catch (e) {
warn(`comment 發布失敗(繼續執行): ${e.message}`);
}
const reviewIntro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
ok('角色介紹 Review 內容已準備');
step('Step2', 'Findings 產生');
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
@@ -111,12 +106,10 @@ async function main() {
const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir);
try {
await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered);
await postNewCriticalComments(filtered);
await postFindingsReview(reviewIntro, filtered);
ok('Step5 完成');
} catch (e) {
warn(`comment 發布失敗(繼續執行): ${e.message}`);
warn(`Review 發布失敗(繼續執行): ${e.message}`);
}
step('Step6', 'JSON 格式驗證');