diff --git a/app/comments.js b/app/comments.js index 94a6210..3ee6e84 100644 --- a/app/comments.js +++ b/app/comments.js @@ -1,11 +1,12 @@ import fs from 'fs'; import path from 'path'; -import { postComment, postPullReviewComment } from './gitea.js'; +import { postComment, postPullReviewComment, postPullReview } from './gitea.js'; import { FINDINGS_PATH } from './config.js'; import { ok, line, warn } from './log.js'; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; +const LEVEL_ORDER = ['critical', 'warning', 'info']; function findingRow(f) { return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`; @@ -17,6 +18,12 @@ function buildTable(findings) { } const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim(); +const bySeverity = (a, b) => { + const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length; + const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length; + if (aLevel !== bLevel) return aLevel - bLevel; + return String(a.location || '').localeCompare(String(b.location || '')); +}; /** * 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。 @@ -36,6 +43,60 @@ function inlineCommentBody(f) { return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; } +function problemText(f) { + return f.problem || f.title || f.message || f.location || '未提供問題位置'; +} + +function reviewCommentBody(f) { + return [ + `**嚴重等級**:${levelText(f)}`, + `**審查員**:${f.role}`, + `**問題**:${problemText(f)}`, + `**建議**:${f.suggestion}`, + ].join('\n'); +} + +function countBy(findings, predicate) { + return findings.filter(predicate).length; +} + +function buildReviewSummary(findings, commentable) { + const unplacedCount = findings.length - commentable.length; + return [ + '## AI Code Review 統計', + '', + `總問題:${findings.length} 筆`, + `可標註檔案與行數:${commentable.length} 筆`, + `無法標註檔案與行數:${unplacedCount} 筆`, + `嚴重:${countBy(findings, f => f.level === 'critical')} 筆`, + `警告:${countBy(findings, f => f.level === 'warning')} 筆`, + `建議:${countBy(findings, f => f.level === 'info')} 筆`, + ].join('\n'); +} + +function toReviewComment(f) { + const loc = parseLocation(f.location); + if (!loc) return null; + return { + path: loc.file, + body: reviewCommentBody(f), + new_position: loc.line, + }; +} + +/** + * 發布單一 Gitea review:本文統計可找出檔案與行數的問題, + * comments 只包含可定位到檔案與行數的 findings,並依嚴重等級排序。 + */ +export async function postFindingsReview(findings, deps = {}) { + const { postReview = postPullReview } = deps; + const sorted = [...findings].sort(bySeverity); + const comments = sorted.map(toReviewComment).filter(Boolean); + const body = buildReviewSummary(sorted, comments); + await postReview({ body, comments }); + ok(`review 發布: total=${sorted.length} commentable=${comments.length}`); +} + /** * 寫入 findings.json。 * 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。 diff --git a/app/gitea.js b/app/gitea.js index 7aeffed..24cccbc 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -136,3 +136,20 @@ export async function postPullReviewComment({ path: filePath, line, body }) { ); return resp.data; } + +/** + * 建立一個 PR review,本文放統計摘要,comments 放多筆行內 review comments。 + */ +export async function postPullReview({ body, comments = [] }) { + const resp = await axios.post( + api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`), + { + commit_id: PR_HEAD_SHA || undefined, + event: 'COMMENT', + body, + comments, + }, + { headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent }, + ); + return resp.data; +} diff --git a/app/main.js b/app/main.js index b793148..e8852ec 100644 --- a/app/main.js +++ b/app/main.js @@ -3,7 +3,7 @@ import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConf import { loadRoles, getRoleIntro } from './roles.js'; import { getPRDiff, postComment, 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'; @@ -107,16 +107,14 @@ async function main() { const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); ok(`Step4 完成: findings total=${filtered.length}`); - step('Step5', 'Findings 寫入與 Comment 發布'); + step('Step5', 'Findings 寫入與 Review 發布'); const reviewDir = repoDir || WORKSPACE; saveFindings(WORKSPACE, filtered, reviewDir); try { - await postOldFindingsComment(filtered); - await postNewNonCriticalComment(filtered); - await postNewCriticalComments(filtered); + await postFindingsReview(filtered); ok('Step5 完成'); } catch (e) { - warn(`comment 發布失敗(繼續執行): ${e.message}`); + warn(`review 發布失敗(繼續執行): ${e.message}`); } step('Step6', 'JSON 格式驗證');