集中 AI Review 留言並補齊 TLS 測試 #26
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
- 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷
|
- 若有提供 `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 一定能用,故獨立驗證
|
- 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` 可連線
|
- 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線
|
||||||
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
|
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),建立一則 AI Review 主 Comment 到 Pull Request
|
||||||
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||||
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
|
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
|
||||||
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
||||||
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後更新到 AI Review 主 Comment
|
||||||
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後更新到 AI Review 主 Comment
|
||||||
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR 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 判斷是否要跳過重跑
|
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)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -55,28 +55,30 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
|
|||||||
/**
|
/**
|
||||||
* 發布所有舊問題 comment(一次發布,依等級排序)
|
* 發布所有舊問題 comment(一次發布,依等級排序)
|
||||||
*/
|
*/
|
||||||
export async function postOldFindingsComment(findings) {
|
export async function postOldFindingsComment(findings, deps = {}) {
|
||||||
|
const { postIssue = postComment } = deps;
|
||||||
const old = findings.filter(f => !f.is_new);
|
const old = findings.filter(f => !f.is_new);
|
||||||
if (old.length === 0) {
|
if (old.length === 0) {
|
||||||
line('無舊問題,跳過');
|
line('無舊問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
|
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
|
||||||
await postComment(body);
|
await postIssue(body);
|
||||||
ok(`舊問題 comment 發布 (${old.length} 筆)`);
|
ok(`舊問題 comment 發布 (${old.length} 筆)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 發布新問題中非 critical 的 comment(一次發布)
|
* 發布新問題中非 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');
|
const items = findings.filter(f => f.is_new && f.level !== 'critical');
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
line('無新的非嚴重問題,跳過');
|
line('無新的非嚴重問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
|
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
|
||||||
await postComment(body);
|
await postIssue(body);
|
||||||
ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`);
|
ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ export async function postNewCriticalComments(findings, deps = {}) {
|
|||||||
}
|
}
|
||||||
for (const f of criticals) {
|
for (const f of criticals) {
|
||||||
const loc = parseLocation(f.location);
|
const loc = parseLocation(f.location);
|
||||||
if (loc) {
|
if (postInline && loc) {
|
||||||
try {
|
try {
|
||||||
await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) });
|
await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) });
|
||||||
ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`);
|
ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`);
|
||||||
|
|||||||
@@ -137,6 +137,17 @@ describe('postNewCriticalComments', () => {
|
|||||||
assert.match(issueCalls[0], /嚴重問題/);
|
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 () => {
|
it('only posts for new critical findings', async () => {
|
||||||
const inlineCalls = [];
|
const inlineCalls = [];
|
||||||
const issueCalls = [];
|
const issueCalls = [];
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(shouldSkipOpenCodeTLSVerify(), false);
|
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', () => {
|
it('openai takes priority over gemini when both set', () => {
|
||||||
process.env.OPENAI_API_KEY = 'sk-test';
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
process.env.GEMINI_API_KEY = 'gemini-key';
|
process.env.GEMINI_API_KEY = 'gemini-key';
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ export async function postComment(body) {
|
|||||||
return resp.data;
|
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(標註程式碼位置)。
|
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。
|
||||||
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
|
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
|
||||||
|
|||||||
+17
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
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());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -57,6 +57,22 @@ describe('gitea', () => {
|
|||||||
await assert.rejects(() => postComment('test'), /api error/);
|
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 () => {
|
it('postPullReviewComment posts an inline review comment to the pulls reviews API', async () => {
|
||||||
let capturedUrl, capturedBody, capturedOpts;
|
let capturedUrl, capturedBody, capturedOpts;
|
||||||
mock.method(axios, 'post', async (url, body, opts) => {
|
mock.method(axios, 'post', async (url, body, opts) => {
|
||||||
|
|||||||
+19
-6
@@ -1,7 +1,7 @@
|
|||||||
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, postComment, updateComment, 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, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
||||||
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||||
@@ -63,9 +63,22 @@ async function main() {
|
|||||||
process.exit(0);
|
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 {
|
try {
|
||||||
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
reviewComment = await postComment(reviewCommentBody);
|
||||||
await postComment(intro);
|
|
||||||
ok('角色介紹 comment 發布成功');
|
ok('角色介紹 comment 發布成功');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
@@ -111,9 +124,9 @@ 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 postOldFindingsComment(filtered, { postIssue: appendReviewComment });
|
||||||
await postNewNonCriticalComment(filtered);
|
await postNewNonCriticalComment(filtered, { postIssue: appendReviewComment });
|
||||||
await postNewCriticalComments(filtered);
|
await postNewCriticalComments(filtered, { postInline: null, postIssue: appendReviewComment });
|
||||||
ok('Step5 完成');
|
ok('Step5 完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
|
|||||||
@@ -199,6 +199,23 @@ describe('verifyLLM', () => {
|
|||||||
assert.equal(agents[1].options.rejectUnauthorized, false);
|
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 () => {
|
it('does not pass an insecure https agent for opencode when TLS verification is enabled', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
||||||
|
|||||||
Reference in New Issue
Block a user