diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 0345147..f28374c 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -381,5 +381,23 @@ "role": "Maya", "location": "app/preflight.js:107", "suggestion": "在 `verifyLLM` 函數中,呼叫 `axios.post` 時缺少 `httpsAgent` 選項。這會導致即使設定了 `GITEA_SKIP_TLS_VERIFY`,LLM 的 API 請求仍可能因 TLS 憑證問題而失敗。請將 `httpsAgent` 傳遞給 `axios.post` 的選項物件,例如:`await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000, httpsAgent });`" + }, + { + "level": "warning", + "role": "Aria", + "location": "app/preflight.test.js:25", + "suggestion": "測試描述使用英文。請確保專案在測試描述的語言上保持一致性。如果專案主要使用繁體中文(如 app/preflight.js 中的 JSDoc 和日誌),則應將此測試描述翻譯為繁體中文。" + }, + { + "level": "info", + "role": "Aria", + "location": "app/preflight.test.js:1-4", + "suggestion": "匯入語句的排序不一致。建議遵循一致的排序規則,例如:內建模組、第三方模組、本地模組,並在各組內按字母順序排序。" + }, + { + "level": "info", + "role": "Aria", + "location": "app/preflight.test.js:14", + "suggestion": "函數名稱 clearLLMEnv 雖然可理解,但可以更具描述性,例如 clearLlmEnvironmentVariables 或 resetLlmEnv。" } ] \ No newline at end of file diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 946ea6a..48848e4 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,37 +1,16 @@ [ { "level": "warning", - "role": "Aria", - "location": "app/preflight.test.js:25", - "suggestion": "測試描述使用英文。請確保專案在測試描述的語言上保持一致性。如果專案主要使用繁體中文(如 `app/preflight.js` 中的 JSDoc 和日誌),則應將此測試描述翻譯為繁體中文。", + "role": "Maya", + "location": "app/comments.test.js:84", + "suggestion": "請為 `postNewCriticalComments` 函數新增一個測試案例,驗證當傳入空的 `findings` 陣列時,函數能正確執行且不發布任何 comment。這能確保邊界條件的處理是符合預期的。", "is_new": true }, { - "level": "info", - "role": "Aria", - "location": "app/preflight.test.js:1-4", - "suggestion": "匯入語句的排序不一致。建議遵循一致的排序規則,例如:內建模組、第三方模組、本地模組,並在各組內按字母順序排序。", - "is_new": true - }, - { - "level": "info", - "role": "Aria", - "location": "app/preflight.test.js:7-12", - "suggestion": "此陣列字面量較長。雖然已分行,但可以考慮將每個元素獨立一行並保持一致的縮排,以提高可讀性。", - "is_new": true - }, - { - "level": "info", - "role": "Aria", - "location": "app/preflight.test.js:14", - "suggestion": "函數名稱 `clearLLMEnv` 雖然可理解,但可以更具描述性,例如 `clearLlmEnvironmentVariables` 或 `resetLlmEnv`。", - "is_new": true - }, - { - "level": "info", - "role": "Aria", - "location": "app/preflight.test.js:149", - "suggestion": "此單行註解風格與其他部分可能不一致。建議遵循專案統一的註解風格指南。", + "level": "warning", + "role": "Maya", + "location": "app/comments.test.js:84", + "suggestion": "目前 `postNewCriticalComments` 的測試案例主要針對單一 critical finding。建議新增一個測試案例,包含多個 `is_new` 且 `level === 'critical'` 的 findings,其中一些可以成功發布行內 comment,另一些則因 `parseLocation` 失敗或 `postInline` 拋出錯誤而降級為一般 comment。這能更全面地驗證迴圈邏輯和多個問題的處理。", "is_new": true } ] diff --git a/README.md b/README.md index dcda95a..82f5a68 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ 4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題 5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request 6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request -7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Pull Request +7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR Comment 8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑 9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) diff --git a/app/comments.js b/app/comments.js index 73d7c25..94a6210 100644 --- a/app/comments.js +++ b/app/comments.js @@ -1,8 +1,8 @@ import fs from 'fs'; import path from 'path'; -import { postComment } from './gitea.js'; +import { postComment, postPullReviewComment } from './gitea.js'; import { FINDINGS_PATH } from './config.js'; -import { ok, line } from './log.js'; +import { ok, line, warn } from './log.js'; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; @@ -16,6 +16,26 @@ function buildTable(findings) { return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`; } +const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim(); + +/** + * 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。 + * 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。 + */ +export function parseLocation(location) { + if (typeof location !== 'string') return null; + const trimmed = location.trim(); + if (trimmed.includes(',')) return null; + const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/); + if (!match) return null; + return { file: match[1], line: Number(match[2]) }; +} + +/** 行內 comment 內容:等級/審查員/建議 */ +function inlineCommentBody(f) { + return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; +} + /** * 寫入 findings.json。 * 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。 @@ -61,17 +81,29 @@ export async function postNewNonCriticalComment(findings) { } /** - * 每個新 critical 問題各發一個 comment + * 每個新 critical 問題各發一個 comment。 + * 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議); + * 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。 */ -export async function postNewCriticalComments(findings) { +export async function postNewCriticalComments(findings, deps = {}) { + const { postInline = postPullReviewComment, postIssue = postComment } = deps; const criticals = findings.filter(f => f.is_new && f.level === 'critical'); if (criticals.length === 0) { line('無新的嚴重問題,跳過'); return; } for (const f of criticals) { - const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`; - await postComment(body); + const loc = parseLocation(f.location); + if (loc) { + try { + await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) }); + ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`); + continue; + } catch (e) { + warn(`行內 comment 發布失敗,改用一般 comment: [${f.role}] ${f.location} error=${e.message}`); + } + } + await postIssue(`## 🚨 嚴重問題\n\n${buildTable([f])}`); ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`); } } diff --git a/app/comments.test.js b/app/comments.test.js index 539ac2b..4e4a96c 100644 --- a/app/comments.test.js +++ b/app/comments.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { saveFindings } from './comments.js'; +import { saveFindings, parseLocation, postNewCriticalComments } from './comments.js'; import { FINDINGS_PATH } from './config.js'; describe('saveFindings', () => { @@ -73,3 +73,81 @@ describe('saveFindings', () => { } }); }); + +describe('parseLocation', () => { + it('parses file and single line', () => { + assert.deepEqual(parseLocation('app/preflight.js:19'), { file: 'app/preflight.js', line: 19 }); + }); + + it('uses the start line for a line range', () => { + assert.deepEqual(parseLocation('app/preflight.js:70-82'), { file: 'app/preflight.js', line: 70 }); + }); + + it('returns null when there is no line number', () => { + assert.equal(parseLocation('app/preflight.test.js'), null); + }); + + it('returns null when multiple files are listed', () => { + assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null); + }); + + it('returns null for non-string input', () => { + assert.equal(parseLocation(undefined), null); + }); +}); + +describe('postNewCriticalComments', () => { + const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true }; + + it('posts an inline review comment annotating file/line with level/role/suggestion', async () => { + const inlineCalls = []; + const issueCalls = []; + await postNewCriticalComments([critical], { + postInline: async (args) => { inlineCalls.push(args); }, + postIssue: async (body) => { issueCalls.push(body); }, + }); + assert.equal(inlineCalls.length, 1); + assert.equal(issueCalls.length, 0); + assert.equal(inlineCalls[0].path, 'app/preflight.js'); + assert.equal(inlineCalls[0].line, 19); + assert.match(inlineCalls[0].body, /等級/); + assert.match(inlineCalls[0].body, /審查員.*Rex/s); + assert.match(inlineCalls[0].body, /建議.*修這個/s); + }); + + it('falls back to a normal comment when the location has no line number', async () => { + const inlineCalls = []; + const issueCalls = []; + await postNewCriticalComments([{ ...critical, location: 'app/preflight.js' }], { + postInline: async (args) => { inlineCalls.push(args); }, + postIssue: async (body) => { issueCalls.push(body); }, + }); + assert.equal(inlineCalls.length, 0); + assert.equal(issueCalls.length, 1); + assert.match(issueCalls[0], /嚴重問題/); + }); + + it('falls back to a normal comment when the inline post fails', async () => { + const issueCalls = []; + await postNewCriticalComments([critical], { + postInline: async () => { throw new Error('line not in diff'); }, + postIssue: async (body) => { issueCalls.push(body); }, + }); + assert.equal(issueCalls.length, 1); + assert.match(issueCalls[0], /嚴重問題/); + }); + + it('only posts for new critical findings', async () => { + const inlineCalls = []; + const issueCalls = []; + await postNewCriticalComments([ + { ...critical, is_new: false }, + { level: 'warning', role: 'Leo', location: 'a.js:1', suggestion: 'x', is_new: true }, + ], { + postInline: async (args) => { inlineCalls.push(args); }, + postIssue: async (body) => { issueCalls.push(body); }, + }); + assert.equal(inlineCalls.length, 0); + assert.equal(issueCalls.length, 0); + }); +}); diff --git a/app/gitea.js b/app/gitea.js index cba10f2..2442ff7 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -127,3 +127,22 @@ export async function postComment(body) { ); return resp.data; } + +/** + * 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。 + * 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。 + * 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。 + */ +export async function postPullReviewComment({ path: filePath, line, body }) { + 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 }], + }, + { headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent }, + ); + return resp.data; +} diff --git a/app/gitea.test.js b/app/gitea.test.js index 5ee8e74..8dc7749 100644 --- a/app/gitea.test.js +++ b/app/gitea.test.js @@ -1,7 +1,7 @@ import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import axios from 'axios'; -import { getPRDiff, filterDiff, postComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; +import { getPRDiff, filterDiff, postComment, postPullReviewComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -57,6 +57,31 @@ describe('gitea', () => { await assert.rejects(() => postComment('test'), /api error/); }); + it('postPullReviewComment posts an inline review comment to the pulls reviews API', async () => { + let capturedUrl, capturedBody, capturedOpts; + mock.method(axios, 'post', async (url, body, opts) => { + capturedUrl = url; + capturedBody = body; + capturedOpts = opts; + return { data: { id: 7 } }; + }); + const result = await postPullReviewComment({ path: 'app/preflight.js', line: 19, body: 'inline body' }); + assert.deepEqual(result, { id: 7 }); + assert.ok(capturedUrl.includes('/api/v1/repos/')); + assert.ok(capturedUrl.endsWith('/reviews')); + assert.equal(capturedBody.event, 'COMMENT'); + assert.equal(capturedBody.comments.length, 1); + assert.equal(capturedBody.comments[0].path, 'app/preflight.js'); + assert.equal(capturedBody.comments[0].new_position, 19); + assert.equal(capturedBody.comments[0].body, 'inline body'); + assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); + }); + + it('postPullReviewComment propagates axios errors', async () => { + mock.method(axios, 'post', async () => { throw new Error('not in diff'); }); + await assert.rejects(() => postPullReviewComment({ path: 'a.js', line: 1, body: 'x' }), /not in diff/); + }); + it('getCommitMessageBySha reads commit message from Gitea API', async () => { let capturedUrl; mock.method(axios, 'get', async (url) => {