From 8e4ea97dacffe848fdff97c3266b054ab4d68ab1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 22 Jun 2026 09:05:29 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(ai-review=20comment):=20=E9=9B=86?= =?UTF-8?q?=E4=B8=AD=E6=9B=B4=E6=96=B0=E5=96=AE=E4=B8=80=E5=AF=A9=E6=9F=A5?= =?UTF-8?q?=E7=95=99=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comments.js | 12 +++++++----- app/gitea.js | 9 +++++++++ app/main.js | 25 +++++++++++++++++++------ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/comments.js b/app/comments.js index 94a6210..a0e99bc 100644 --- a/app/comments.js +++ b/app/comments.js @@ -55,28 +55,30 @@ export function saveFindings(workspace, findings, mirrorDir = null) { /** * 發布所有舊問題 comment(一次發布,依等級排序) */ -export async function postOldFindingsComment(findings) { +export async function postOldFindingsComment(findings, deps = {}) { + const { postIssue = postComment } = deps; const old = findings.filter(f => !f.is_new); if (old.length === 0) { line('無舊問題,跳過'); return; } const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`; - await postComment(body); + await postIssue(body); ok(`舊問題 comment 發布 (${old.length} 筆)`); } /** * 發布新問題中非 critical 的 comment(一次發布) */ -export async function postNewNonCriticalComment(findings) { +export async function postNewNonCriticalComment(findings, deps = {}) { + const { postIssue = postComment } = deps; const items = findings.filter(f => f.is_new && f.level !== 'critical'); if (items.length === 0) { line('無新的非嚴重問題,跳過'); return; } const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`; - await postComment(body); + await postIssue(body); ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`); } @@ -94,7 +96,7 @@ export async function postNewCriticalComments(findings, deps = {}) { } for (const f of criticals) { const loc = parseLocation(f.location); - if (loc) { + if (postInline && loc) { try { await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) }); ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`); diff --git a/app/gitea.js b/app/gitea.js index 7aeffed..76e7873 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -118,6 +118,15 @@ export async function postComment(body) { return resp.data; } +export async function updateComment(commentId, body) { + const resp = await axios.patch( + api(`/repos/${GITEA_REPOSITORY}/issues/comments/${commentId}`), + { body }, + { headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent }, + ); + return resp.data; +} + /** * 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。 * 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。 diff --git a/app/main.js b/app/main.js index b793148..c7c0e80 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ 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, postComment, updateComment, 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 { cloneRepo, commitAndPush, getRepoState } from './git.js'; @@ -63,9 +63,22 @@ async function main() { process.exit(0); } + let reviewComment = null; + let reviewCommentBody = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; + const appendReviewComment = async (body) => { + if (!body) return null; + if (!reviewComment?.id) { + reviewCommentBody = body; + reviewComment = await postComment(reviewCommentBody); + return reviewComment; + } + reviewCommentBody += `\n\n---\n\n${body}`; + reviewComment = await updateComment(reviewComment.id, reviewCommentBody); + return reviewComment; + }; + try { - const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; - await postComment(intro); + reviewComment = await postComment(reviewCommentBody); ok('角色介紹 comment 發布成功'); } catch (e) { warn(`comment 發布失敗(繼續執行): ${e.message}`); @@ -111,9 +124,9 @@ async function main() { const reviewDir = repoDir || WORKSPACE; saveFindings(WORKSPACE, filtered, reviewDir); try { - await postOldFindingsComment(filtered); - await postNewNonCriticalComment(filtered); - await postNewCriticalComments(filtered); + await postOldFindingsComment(filtered, { postIssue: appendReviewComment }); + await postNewNonCriticalComment(filtered, { postIssue: appendReviewComment }); + await postNewCriticalComments(filtered, { postInline: null, postIssue: appendReviewComment }); ok('Step5 完成'); } catch (e) { warn(`comment 發布失敗(繼續執行): ${e.message}`); -- 2.53.0 From f0544bb758c012e5e2dc28490209cad73f3707fd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 22 Jun 2026 09:05:31 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(ai-review=20comment):=20=E8=A3=9C?= =?UTF-8?q?=E9=BD=8A=E7=95=99=E8=A8=80=E6=9B=B4=E6=96=B0=E8=88=87=20TLS=20?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E8=A6=86=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comments.test.js | 11 +++++++++++ app/config.test.js | 7 +++++++ app/gitea.test.js | 18 +++++++++++++++++- app/preflight.test.js | 17 +++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/app/comments.test.js b/app/comments.test.js index 78b0e0f..0f8dc33 100644 --- a/app/comments.test.js +++ b/app/comments.test.js @@ -137,6 +137,17 @@ describe('postNewCriticalComments', () => { assert.match(issueCalls[0], /嚴重問題/); }); + it('posts critical findings to the issue updater when inline comments are disabled', async () => { + const issueCalls = []; + await postNewCriticalComments([critical], { + postInline: null, + postIssue: async (body) => { issueCalls.push(body); }, + }); + assert.equal(issueCalls.length, 1); + assert.match(issueCalls[0], /嚴重問題/); + assert.match(issueCalls[0], /app\/preflight\.js:19/); + }); + it('only posts for new critical findings', async () => { const inlineCalls = []; const issueCalls = []; diff --git a/app/config.test.js b/app/config.test.js index dec24c9..4286e1a 100644 --- a/app/config.test.js +++ b/app/config.test.js @@ -114,6 +114,13 @@ describe('getLLMConfig', () => { assert.equal(shouldSkipOpenCodeTLSVerify(), false); }); + it('skips OpenCode TLS verification for non-false values', () => { + for (const value of ['', '0', 'true', 'yes']) { + process.env.OPENCODE_SKIP_TLS_VERIFY = value; + assert.equal(shouldSkipOpenCodeTLSVerify(), true); + } + }); + it('openai takes priority over gemini when both set', () => { process.env.OPENAI_API_KEY = 'sk-test'; process.env.GEMINI_API_KEY = 'gemini-key'; diff --git a/app/gitea.test.js b/app/gitea.test.js index 89c5a3a..a7a7865 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, postPullReviewComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; +import { getPRDiff, filterDiff, postComment, updateComment, postPullReviewComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; afterEach(() => mock.restoreAll()); @@ -57,6 +57,22 @@ describe('gitea', () => { await assert.rejects(() => postComment('test'), /api error/); }); + it('updateComment patches an existing issue comment with body', async () => { + let capturedUrl, capturedBody, capturedOpts; + mock.method(axios, 'patch', async (url, body, opts) => { + capturedUrl = url; + capturedBody = body; + capturedOpts = opts; + return { data: { id: 123, body: body.body } }; + }); + const result = await updateComment(123, 'updated body'); + assert.deepEqual(result, { id: 123, body: 'updated body' }); + assert.ok(capturedUrl.includes('/api/v1/repos/')); + assert.ok(capturedUrl.endsWith('/issues/comments/123')); + assert.equal(capturedBody.body, 'updated body'); + assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); + }); + 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) => { diff --git a/app/preflight.test.js b/app/preflight.test.js index 0270c33..6cc0d33 100644 --- a/app/preflight.test.js +++ b/app/preflight.test.js @@ -199,6 +199,23 @@ describe('verifyLLM', () => { assert.equal(agents[1].options.rejectUnauthorized, false); }); + it('passes an insecure https agent for opencode when TLS skip is explicitly true', async () => { + clearLLMEnv(); + process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; + process.env.OPENCODE_SKIP_TLS_VERIFY = 'true'; + const agents = []; + mock.method(axios, 'get', async (url, opts) => { + agents.push(opts.httpsAgent); + if (url.endsWith('/global/health')) return { data: { healthy: true } }; + return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; + }); + const result = await verifyLLM(); + assert.equal(result.ok, true); + assert.equal(agents.length, 2); + assert.equal(agents[0].options.rejectUnauthorized, false); + assert.equal(agents[1].options.rejectUnauthorized, false); + }); + it('does not pass an insecure https agent for opencode when TLS verification is enabled', async () => { clearLLMEnv(); process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; -- 2.53.0 From 3b4ea2dad095308807bc1f3246feea89a9054e78 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 22 Jun 2026 09:05:34 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(README):=20=E6=9B=B4=E6=96=B0=20AI=20R?= =?UTF-8?q?eview=20=E7=95=99=E8=A8=80=E6=B5=81=E7=A8=8B=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3fd5a0f..9a2875e 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ - 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷 - git push 認證可用:用與第 8 點 commit/push 完全相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(例如 `could not read Username`)的問題。此路徑與上面的 REST API 不同,API token 有效不代表 git push 一定能用,故獨立驗證 - 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線 -1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request +1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),建立一則 AI Review 主 Comment 到 Pull Request 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案 4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題 -5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request -6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request -7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR Comment +5. 從 PR 問題表格中取出所有舊問題,依照等級排序後更新到 AI Review 主 Comment +6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後更新到 AI Review 主 Comment +7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題更新到 AI Review 主 Comment;後續問題區塊都以更新同一則 Comment 的方式集中呈現,避免 PR 對話串被多則 bot comment 洗版 8. Commit 問題檔案,只將 workspace 中實際存在的 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 覆蓋到記憶區;workspace 沒有的問題檔就略過。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑 9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) -- 2.53.0 From ff9413e48bb3ea4207b5686c9837e61b357c9ac5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 22 Jun 2026 09:05:37 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=B7=B2=E8=A7=A3=E6=B1=BA=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/findings.json | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index ff3c0a8..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,16 +1 @@ -[ - { - "level": "warning", - "role": "Mage", - "location": "app/config.test.js", - "suggestion": "`shouldSkipOpenCodeTLSVerify` 函式的新增測試案例未能涵蓋所有可能的輸入情境。在 `process.env.OPENCODE_SKIP_TLS_VERIFY !== 'false'` 的新邏輯下,應增加測試案例來驗證當環境變數設定為空字串 `''`、字串 `'0'` 或其他任意非 `'false'` 字串時,函式是否如預期般返回 `true`(跳過 TLS 驗證)。這有助於確保此關鍵安全邏輯的行為符合預期,並揭示潛在的誤配置風險。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/preflight.test.js", - "suggestion": "在 `preflight.test.js` 中,關於 `httpsAgent` 的測試案例也已涵蓋了預設行為(跳過 TLS)和明確設定為 `false`(不跳過 TLS)的情況。請新增一個測試,驗證當環境變數 `process.env.OPENCODE_SKIP_TLS_VERIFY` 明確設定為 `'true'` 時,`verifyLLM` 函式是否會傳遞一個不安全的 `httpsAgent` 給 OpenCode 服務進行預檢。", - "is_new": true - } -] +[] -- 2.53.0