Merge pull request '調整 AI review 以單一 Review 發布統計與 comments' (#28) from ai-review-resolve/20260622100149 into develop
Reviewed-on: actions/code-review#28
This commit was merged in pull request #28.
This commit is contained in:
@@ -380,5 +380,17 @@
|
||||
"role": "Bard",
|
||||
"location": "app/preflight.test.js:14",
|
||||
"suggestion": "函數名稱 clearLLMEnv 雖然可理解,但可以更具描述性,例如 clearLlmEnvironmentVariables 或 resetLlmEnv。"
|
||||
},
|
||||
{
|
||||
"location": "app/config.test.js:114",
|
||||
"role": "Mage",
|
||||
"original_finding": "新加入的測試案例 `it('skips OpenCode TLS verification for empty string and non-false values', ...)` 預期 `shouldSkipOpenCodeTLSVerify()` 函式在 `OPENCODE_SKIP_TLS_VERIFY` 環境變數為空字串 `''` 或 `'0'` 時,會回傳 `true`。然而,根據常見的環境變數布林值解析邏輯,以及 `app/preflight.test.js` 中現有的相關測試(例如未設定時為 `false`,設定為 `'false'` 時為 `false`),`shouldSkipOpenCodeTLSVerify()` 函式(此 PR 未修改其內容)很可能不會將 `''` 或 `'0'` 視為 `true`。這造成了測試預期與函式實際行為之間的邏輯不一致。",
|
||||
"reason": "誤判。`shouldSkipOpenCodeTLSVerify` 的既有設計是只有 `OPENCODE_SKIP_TLS_VERIFY === 'false'` 才啟用 TLS 驗證;空字串與 '0' 皆屬非 'false' 值,測試符合目前明確實作與預設跳過 TLS 的行為。"
|
||||
},
|
||||
{
|
||||
"location": "app/preflight.test.js:201",
|
||||
"role": "Assassin",
|
||||
"original_finding": "此測試明確證實了 `OPENCODE_SKIP_TLS_VERIFY` 環境變數的寬鬆判斷邏輯,導致 OpenCode LLM 連線的 TLS 驗證容易被關閉。這是「關閉 TLS 驗證」的不安全預設,極大地增加了中間人攻擊的風險。",
|
||||
"reason": "誤判/既有設計。OpenCode server 目前支援自簽或內部服務情境,action input 與 README 均明確標示 OPENCODE_SKIP_TLS_VERIFY 預設跳過 TLS 驗證;本 PR 只補測試與 Review comment 內容,未新增或放寬此安全行為。"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -4,19 +4,18 @@
|
||||
|
||||
# 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
||||
|
||||
0. 前置驗證(action 最開始執行、做任何分析或發 comment 前):檢查所有驗證相關設定是否都可用,全部通過才繼續;任何一項失敗就印出明確訊息並立即 `exit 1`
|
||||
1. Pipeline 啟動:輸出 repo、PR 編號、來源分支與目標分支等基本資訊
|
||||
2. 前置驗證(做任何分析或發 comment 前):檢查所有驗證相關設定是否都可用,全部通過才繼續;任何一項失敗就印出明確訊息並立即 `exit 1`
|
||||
- 必要環境變數齊全:`GITEA_TOKEN`、`GITEA_REPOSITORY`、`PR_NUMBER`(缺一即失敗)
|
||||
- Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效)
|
||||
- 若有提供 `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
|
||||
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
|
||||
3. 檢查是否為 AI 助理自動提交;若不是,選定 LLM provider/model、載入角色、取得 PR diff,將服務名稱、模型名稱與角色資訊 Comment 到 Pull Request,並讓每個角色個別分析 Git Diff 產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||
4. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
|
||||
5. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
||||
6. 將 PR 問題表格寫入 `.gitea/ai-review/findings.json`,並發布一個 Gitea Review:Review 本文用「嚴重/警告/建議」三欄統計各等級數量;之後將可找出檔案與行數的問題依照嚴重等級排序後加入 Review Comments 內,每個 Comment 包含嚴重等級/審查員/問題/建議,其中「問題」是審查員判斷該處有問題的原因,不是檔案路徑或行號
|
||||
7. 驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON array;格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;檔案不存在則建立並寫入 `[]`
|
||||
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)
|
||||
|
||||
@@ -30,9 +29,9 @@
|
||||
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
|
||||
7. 讀取 Git Diff 時排除 `.gitea/`、`.github/` 資料夾,以及 `TODO.md`、`README.md`,避免 AI 分析 workflow 設定與文件等非業務程式碼
|
||||
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
||||
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、problem、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
||||
11. action 一啟動就先做「前置驗證」(流程第 0 點):集中檢查 Gitea REST API token、comment token、git push 認證與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js`(git push 驗證委派給 `app/git.js` 的 `verifyRemoteAccess`),由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半、發 comment 或最後 push 時才因 token / key / 認證無效而中斷
|
||||
11. action 一啟動就先做「前置驗證」(流程第 2 點):集中檢查 Gitea REST API token、comment token、git push 認證與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js`(git push 驗證委派給 `app/git.js` 的 `verifyRemoteAccess`),由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半、發 comment 或最後 push 時才因 token / key / 認證無效而中斷
|
||||
|
||||
# 使用說明
|
||||
|
||||
|
||||
+61
-1
@@ -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,59 @@ function inlineCommentBody(f) {
|
||||
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
||||
}
|
||||
|
||||
function problemText(f) {
|
||||
return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因';
|
||||
}
|
||||
|
||||
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) {
|
||||
const criticalCount = countBy(findings, f => f.level === 'critical');
|
||||
const warningCount = countBy(findings, f => f.level === 'warning');
|
||||
const infoCount = countBy(findings, f => f.level === 'info');
|
||||
return [
|
||||
'## AI Code Review 統計',
|
||||
'',
|
||||
'| 🔴 嚴重 | 🟡 警告 | 🔵 建議 |',
|
||||
'| --- | --- | --- |',
|
||||
`| ${criticalCount} 筆 | ${warningCount} 筆 | ${infoCount} 筆 |`,
|
||||
].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);
|
||||
await postReview({ body, comments });
|
||||
ok(`review 發布: total=${sorted.length} commentable=${comments.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入 findings.json。
|
||||
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
||||
|
||||
+72
-1
@@ -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, parseLocation, postNewCriticalComments } from './comments.js';
|
||||
import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview } from './comments.js';
|
||||
import { FINDINGS_PATH } from './config.js';
|
||||
|
||||
describe('saveFindings', () => {
|
||||
@@ -185,3 +185,74 @@ describe('postNewCriticalComments', () => {
|
||||
assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('postFindingsReview', () => {
|
||||
it('posts one review with statistics and sorted line comments', async () => {
|
||||
const reviewCalls = [];
|
||||
const findings = [
|
||||
{ level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'I', is_new: true },
|
||||
{ level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'C', is_new: false },
|
||||
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
|
||||
];
|
||||
|
||||
await postFindingsReview(findings, {
|
||||
postReview: async (args) => { reviewCalls.push(args); },
|
||||
});
|
||||
|
||||
assert.equal(reviewCalls.length, 1);
|
||||
assert.match(reviewCalls[0].body, /\| 🔴 嚴重 \| 🟡 警告 \| 🔵 建議 \|/);
|
||||
assert.match(reviewCalls[0].body, /\| 1 筆 \| 1 筆 \| 1 筆 \|/);
|
||||
assert.deepEqual(
|
||||
reviewCalls[0].comments.map(c => c.path),
|
||||
['app/a.js', 'app/b.js', 'app/c.js'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
reviewCalls[0].comments.map(c => c.new_position),
|
||||
[10, 20, 30],
|
||||
);
|
||||
assert.match(reviewCalls[0].comments[0].body, /嚴重等級/);
|
||||
assert.match(reviewCalls[0].comments[0].body, /審查員.*Rex/s);
|
||||
assert.match(reviewCalls[0].comments[0].body, /問題.*未提供問題原因/s);
|
||||
assert.doesNotMatch(reviewCalls[0].comments[0].body, /問題.*app\/a\.js:10/s);
|
||||
assert.match(reviewCalls[0].comments[0].body, /建議.*C/s);
|
||||
});
|
||||
|
||||
it('only adds comments for findings with parseable file and line', async () => {
|
||||
const reviewCalls = [];
|
||||
await postFindingsReview([
|
||||
{ level: 'critical', role: 'Rex', location: 'app/a.js', suggestion: 'missing line', is_new: true },
|
||||
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'line', is_new: true },
|
||||
], {
|
||||
postReview: async (args) => { reviewCalls.push(args); },
|
||||
});
|
||||
|
||||
assert.equal(reviewCalls.length, 1);
|
||||
assert.match(reviewCalls[0].body, /\| 1 筆 \| 1 筆 \| 0 筆 \|/);
|
||||
assert.equal(reviewCalls[0].comments.length, 1);
|
||||
assert.equal(reviewCalls[0].comments[0].path, 'app/b.js');
|
||||
});
|
||||
|
||||
it('uses an explicit problem field when present', async () => {
|
||||
const reviewCalls = [];
|
||||
await postFindingsReview([
|
||||
{ level: 'warning', role: 'Leo', location: 'app/a.js:5', problem: '命名不清楚', suggestion: '改成具體名稱' },
|
||||
], {
|
||||
postReview: async (args) => { reviewCalls.push(args); },
|
||||
});
|
||||
|
||||
assert.match(reviewCalls[0].comments[0].body, /問題.*命名不清楚/s);
|
||||
assert.match(reviewCalls[0].comments[0].body, /建議.*改成具體名稱/s);
|
||||
});
|
||||
|
||||
it('uses reviewer reason fields as the problem text instead of the location', async () => {
|
||||
const reviewCalls = [];
|
||||
await postFindingsReview([
|
||||
{ level: 'warning', role: 'Leo', location: 'app/a.js:5', description: '這裡缺少空值檢查', suggestion: '先判斷 null 再使用' },
|
||||
], {
|
||||
postReview: async (args) => { reviewCalls.push(args); },
|
||||
});
|
||||
|
||||
assert.match(reviewCalls[0].comments[0].body, /問題.*這裡缺少空值檢查/s);
|
||||
assert.doesNotMatch(reviewCalls[0].comments[0].body, /問題.*app\/a\.js:5/s);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,13 @@ describe('getLLMConfig', () => {
|
||||
assert.equal(shouldSkipOpenCodeTLSVerify(), false);
|
||||
});
|
||||
|
||||
it('skips OpenCode TLS verification for empty string and non-false values', () => {
|
||||
for (const value of ['', '0', 'true', 'yes', '1', 'on', 'custom']) {
|
||||
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';
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ function fallback(label, findings, e) {
|
||||
|
||||
/** 只保留 AI 需要的欄位,減少 token 用量 */
|
||||
function toAIPayload(findings) {
|
||||
return findings.map(({ level, role, location, suggestion }) => ({ level, role, location, suggestion }));
|
||||
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('findings exclusions', () => {
|
||||
|
||||
it('builds a compact exclusion hint for AI', async () => {
|
||||
const findings = [
|
||||
{ level: 'warning', role: 'Maya', location: 'src/app.cs:12', suggestion: 'update tests' },
|
||||
{ level: 'warning', role: 'Maya', location: 'src/app.cs:12', problem: '缺少測試驗證', suggestion: 'update tests' },
|
||||
];
|
||||
const exclusions = [
|
||||
{ location: 'src/app.cs:1', original_finding: '更新套件後請補上測試驗證' },
|
||||
@@ -138,6 +138,7 @@ describe('findings exclusions', () => {
|
||||
assert.ok(capturedSystemPrompt.includes('paths=src/app.cs, src/service.cs'));
|
||||
assert.ok(capturedSystemPrompt.includes('請確認安全性變更'));
|
||||
assert.ok(capturedUserContent.includes('"location":"src/app.cs:12"'));
|
||||
assert.ok(capturedUserContent.includes('"problem":"缺少測試驗證"'));
|
||||
assert.ok(capturedUserContent.includes('"suggestion":"update tests"'));
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
||||
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||||
ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
|
||||
} catch (pushErr) {
|
||||
warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
||||
warn(`Step8 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ describe('commitAndPush', () => {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
|
||||
assert.ok(logs.some(line => line.includes('Step8 commit 成功但 push 失敗')));
|
||||
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+27
-1
@@ -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, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
|
||||
|
||||
afterEach(() => mock.restoreAll());
|
||||
|
||||
@@ -82,6 +82,32 @@ describe('gitea', () => {
|
||||
await assert.rejects(() => postPullReviewComment({ path: 'a.js', line: 1, body: 'x' }), /not in diff/);
|
||||
});
|
||||
|
||||
it('postPullReview posts one review with multiple comments', async () => {
|
||||
let capturedUrl, capturedBody, capturedOpts;
|
||||
mock.method(axios, 'post', async (url, body, opts) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = body;
|
||||
capturedOpts = opts;
|
||||
return { data: { id: 9 } };
|
||||
});
|
||||
|
||||
const result = await postPullReview({
|
||||
body: 'summary',
|
||||
comments: [{ path: 'app/a.js', new_position: 10, body: 'comment' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { id: 9 });
|
||||
assert.ok(capturedUrl.includes('/api/v1/repos/'));
|
||||
assert.ok(capturedUrl.endsWith('/reviews'));
|
||||
assert.equal(capturedBody.event, 'COMMENT');
|
||||
assert.equal(capturedBody.body, 'summary');
|
||||
assert.equal(capturedBody.comments.length, 1);
|
||||
assert.equal(capturedBody.comments[0].path, 'app/a.js');
|
||||
assert.equal(capturedBody.comments[0].new_position, 10);
|
||||
assert.equal(capturedBody.comments[0].body, 'comment');
|
||||
assert.ok(capturedOpts.headers['Authorization'].startsWith('token '));
|
||||
});
|
||||
|
||||
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
||||
let capturedUrl;
|
||||
mock.method(axios, 'get', async (url) => {
|
||||
|
||||
+15
-17
@@ -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';
|
||||
@@ -71,7 +71,7 @@ async function main() {
|
||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||
}
|
||||
|
||||
step('Step2', 'Findings 產生');
|
||||
step('Step3', 'Findings 產生');
|
||||
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
||||
const newFindings = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
@@ -81,9 +81,9 @@ async function main() {
|
||||
warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`);
|
||||
}
|
||||
}
|
||||
ok(`Step2 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||
ok(`Step3 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||
|
||||
step('Step3', 'Findings 合併與語意去重');
|
||||
step('Step4', 'Findings 合併與語意去重');
|
||||
let repoDir;
|
||||
try {
|
||||
repoDir = cloneRepo(WORKSPACE);
|
||||
@@ -96,30 +96,28 @@ async function main() {
|
||||
}
|
||||
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
||||
const mergedFindings = mergeFindings(oldFindings, newFindings);
|
||||
ok(`Step3 merged findings total=${mergedFindings.length}`);
|
||||
ok(`Step4 merged findings total=${mergedFindings.length}`);
|
||||
const deduped = await deduplicateWithAI(mergedFindings);
|
||||
const sorted = sortByLevel(deduped);
|
||||
ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
|
||||
ok(`Step4 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
|
||||
|
||||
step('Step4', 'AI 排除問題過濾');
|
||||
step('Step5', 'AI 排除問題過濾');
|
||||
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
|
||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||
ok(`Step4 完成: findings total=${filtered.length}`);
|
||||
ok(`Step5 完成: findings total=${filtered.length}`);
|
||||
|
||||
step('Step5', 'Findings 寫入與 Comment 發布');
|
||||
step('Step6', 'Findings 寫入與 Review 發布');
|
||||
const reviewDir = repoDir || WORKSPACE;
|
||||
saveFindings(WORKSPACE, filtered, reviewDir);
|
||||
try {
|
||||
await postOldFindingsComment(filtered);
|
||||
await postNewNonCriticalComment(filtered);
|
||||
await postNewCriticalComments(filtered);
|
||||
ok('Step5 完成');
|
||||
await postFindingsReview(filtered);
|
||||
ok('Step6 完成');
|
||||
} catch (e) {
|
||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||
warn(`review 發布失敗(繼續執行): ${e.message}`);
|
||||
}
|
||||
|
||||
step('Step6', 'JSON 格式驗證');
|
||||
step('Step7', 'JSON 格式驗證');
|
||||
const missingPaths = [];
|
||||
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
|
||||
const fullPath = path.join(reviewDir, relPath);
|
||||
@@ -135,12 +133,12 @@ async function main() {
|
||||
ensureJSONArrayFileExists(fullPath, relPath);
|
||||
}
|
||||
|
||||
step('Step7', '記憶區 Commit/Push');
|
||||
step('Step8', '記憶區 Commit/Push');
|
||||
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
|
||||
line(`review outcome=${reviewOutcome}`);
|
||||
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome);
|
||||
|
||||
step('Step8', '嚴重問題檢查');
|
||||
step('Step9', '嚴重問題檢查');
|
||||
const criticalCount = filtered.filter(f => f.level === 'critical').length;
|
||||
if (criticalCount > 0) {
|
||||
error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
|
||||
verifyRemote = verifyRemoteAccess,
|
||||
verifyLLMFn = verifyLLM,
|
||||
} = deps;
|
||||
step('Step1.5', '前置驗證(驗證相關設定)');
|
||||
step('Step2', '前置驗證(驗證相關設定)');
|
||||
|
||||
const env = checkEnv();
|
||||
if (!env.ok) {
|
||||
|
||||
@@ -199,6 +199,26 @@ describe('verifyLLM', () => {
|
||||
assert.equal(agents[1].options.rejectUnauthorized, false);
|
||||
});
|
||||
|
||||
it('passes an insecure https agent for opencode when TLS skip is any non-false value', async () => {
|
||||
for (const value of ['true', '', '0', 'yes', '1', 'on']) {
|
||||
clearLLMEnv();
|
||||
mock.restoreAll();
|
||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
||||
process.env.OPENCODE_SKIP_TLS_VERIFY = value;
|
||||
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
-1
@@ -39,7 +39,7 @@ function readRoleFiles() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 載入攻擊方角色(Step2 產生 findings 用),依檔名排序。
|
||||
* 載入攻擊方角色(Step3 產生 findings 用),依檔名排序。
|
||||
* 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。
|
||||
*/
|
||||
export function loadRoles() {
|
||||
@@ -71,6 +71,7 @@ export function buildAnalysisPrompt(role) {
|
||||
' "level": "critical|warning|info",',
|
||||
` "role": "${role.name}",`,
|
||||
' "location": "檔案路徑:行號 或 檔案路徑",',
|
||||
' "problem": "繁體中文(台灣用語)說明審查員認為這裡有問題的原因,不要只填檔案路徑或行號",',
|
||||
' "suggestion": "繁體中文(台灣用語)的具體修改建議"',
|
||||
'}',
|
||||
'',
|
||||
|
||||
@@ -68,6 +68,8 @@ describe('buildAnalysisPrompt', () => {
|
||||
it('embeds the role name in the JSON contract and persona/body', () => {
|
||||
const prompt = buildAnalysisPrompt(parseRoleFile(SAMPLE));
|
||||
assert.match(prompt, /"role": "Tester"/);
|
||||
assert.match(prompt, /"problem":/);
|
||||
assert.match(prompt, /有問題的原因/);
|
||||
assert.match(prompt, /冷靜嚴謹/);
|
||||
assert.match(prompt, /審查重點:邊界與空值/);
|
||||
assert.match(prompt, /只回傳 JSON 陣列/);
|
||||
|
||||
Reference in New Issue
Block a user