From 1477822f4bee116b7984f6785363693d0efeab81 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 22 Jun 2026 09:40:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(review=20=E7=99=BC=E5=B8=83):=20=E5=B0=87?= =?UTF-8?q?=20AI=20=E5=AF=A9=E6=9F=A5=E7=B5=90=E6=9E=9C=E9=9B=86=E4=B8=AD?= =?UTF-8?q?=E5=88=B0=E5=90=8C=E4=B8=80=E7=AD=86=20Pull=20Review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comments.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++- app/gitea.js | 24 ++++++++++++------ app/main.js | 19 +++++--------- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/app/comments.js b/app/comments.js index 94a6210..3551ba8 100644 --- a/app/comments.js +++ b/app/comments.js @@ -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 使用。 diff --git a/app/gitea.js b/app/gitea.js index 7aeffed..0ba2313 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -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 }], + }); +} diff --git a/app/main.js b/app/main.js index b793148..238c1bf 100644 --- a/app/main.js +++ b/app/main.js @@ -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 格式驗證');