feat(review 發布): 將 AI 審查結果集中到同一筆 Pull Review
This commit is contained in:
+65
-1
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { postComment, postPullReviewComment } from './gitea.js';
|
import { createPullReview, postComment, postPullReviewComment } from './gitea.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
import { ok, line, warn } from './log.js';
|
import { ok, line, warn } from './log.js';
|
||||||
|
|
||||||
@@ -36,6 +36,70 @@ function inlineCommentBody(f) {
|
|||||||
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
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。
|
* 寫入 findings.json。
|
||||||
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
||||||
|
|||||||
+17
-7
@@ -119,20 +119,30 @@ export async function postComment(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。
|
* 建立一筆 Pull Review。body 會成為 review 主內容,comments 會成為同一筆 review 底下的行內 comments。
|
||||||
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
|
|
||||||
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。
|
|
||||||
*/
|
*/
|
||||||
export async function postPullReviewComment({ path: filePath, line, body }) {
|
export async function createPullReview({ body, comments = [], event = 'COMMENT' }) {
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
|
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
|
||||||
{
|
{
|
||||||
commit_id: PR_HEAD_SHA || undefined,
|
commit_id: PR_HEAD_SHA || undefined,
|
||||||
event: 'COMMENT',
|
event,
|
||||||
body: '',
|
body,
|
||||||
comments: [{ path: filePath, body, new_position: line }],
|
comments,
|
||||||
},
|
},
|
||||||
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
|
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
|
||||||
);
|
);
|
||||||
return resp.data;
|
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
@@ -1,9 +1,9 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
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 { 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 { 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 { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
import { runPreflight } from './preflight.js';
|
import { runPreflight } from './preflight.js';
|
||||||
@@ -63,13 +63,8 @@ async function main() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const reviewIntro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
||||||
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
ok('角色介紹 Review 內容已準備');
|
||||||
await postComment(intro);
|
|
||||||
ok('角色介紹 comment 發布成功');
|
|
||||||
} catch (e) {
|
|
||||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
step('Step2', 'Findings 產生');
|
step('Step2', 'Findings 產生');
|
||||||
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
||||||
@@ -111,12 +106,10 @@ async function main() {
|
|||||||
const reviewDir = repoDir || WORKSPACE;
|
const reviewDir = repoDir || WORKSPACE;
|
||||||
saveFindings(WORKSPACE, filtered, reviewDir);
|
saveFindings(WORKSPACE, filtered, reviewDir);
|
||||||
try {
|
try {
|
||||||
await postOldFindingsComment(filtered);
|
await postFindingsReview(reviewIntro, filtered);
|
||||||
await postNewNonCriticalComment(filtered);
|
|
||||||
await postNewCriticalComments(filtered);
|
|
||||||
ok('Step5 完成');
|
ok('Step5 完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`Review 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
step('Step6', 'JSON 格式驗證');
|
step('Step6', 'JSON 格式驗證');
|
||||||
|
|||||||
Reference in New Issue
Block a user