Compare commits

..

6 Commits

Author SHA1 Message Date
Jeffery f047b4473e chore(ai-review): 三條 preflight 測試誤判寫入 exclusions 並清空 findings
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Successful in 1m17s
2026-06-16 16:00:17 +08:00
AI Review Bot 8419e60848 chore: update ai-review findings [ai-review-bot][success]
AI / 計算版本號 (pull_request) Successful in 2s
AI / Code Review (pull_request) Successful in 2s
2026-06-16 06:46:45 +00:00
Jeffery caebd2b112 feat: 嚴重問題改用 Gitea 行內 review comment 標註檔案行數
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Successful in 1m35s
每個新的嚴重問題改以行內 review comment 標註在問題所在的檔案與行數上,
留言內容為等級/審查員/建議;無法解析出行號(未標行號或一次列出多個
檔案),或行內留言失敗(該行不在 diff 範圍)時,降級為原本的一般 PR comment。

- gitea.js:新增 postPullReviewComment,呼叫 pull reviews API,以 new_position
  對應新版檔案行號、commit_id 帶 PR_HEAD_SHA
- comments.js:新增 parseLocation(支援 file:19 / file:70-82,取起始行)與
  行內留言內容組裝;postNewCriticalComments 先試行內、失敗降級,deps 可注入
- 補 11 個測試(API payload、parseLocation 各情境、行內成功與兩種降級路徑)
- README 更新流程第 7 步說明

app/ 測試 123 pass。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:40:36 +08:00
jiantw83 7caf3d0490 Merge pull request 'feat: 前置驗證納入 git push 認證檢查' (#10) from feat/preflight-auth-check into develop
Reviewed-on: #10
2026-06-16 06:20:09 +00:00
AI Review Bot fce2cd3c45 chore: update ai-review findings [ai-review-bot][success]
AI / 計算版本號 (pull_request) Successful in 3s
AI / Code Review (pull_request) Successful in 4s
2026-06-16 06:19:36 +00:00
Jeffery 33f1291a0f chore: triage preflight TLS finding 為誤報並寫入 exclusions
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Successful in 4m16s
Maya critical(app/preflight.js:107):verifyLLM 的 axios.post 未帶
httpsAgent,認為 GITEA_SKIP_TLS_VERIFY 未套用到 LLM 請求。

判定為誤報並移入 exclusions:
- GITEA_SKIP_TLS_VERIFY 為 Gitea 端(內網自簽憑證)專用設定,外部 LLM
  服務(Gemini/OpenAI/Claude)應維持 TLS 驗證,套用此 flag 屬安全降級
- 與既有 app/llm.js 排除一致(已刻意移除 rejectUnauthorized:false 還原
  TLS 驗證)

findings.json 清空(已排除)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:15:00 +08:00
7 changed files with 187 additions and 18 deletions
+23
View File
@@ -376,5 +376,28 @@
"role": "Aria", "role": "Aria",
"location": "app/preflight.js:30", "location": "app/preflight.js:30",
"suggestion": "在 `checkRequiredEnv`、`verifyGiteaToken` 和 `verifyCommentToken` 等函式中,預設參數直接引用了從 `config.js` 匯入的常數。雖然這在功能上可行,但為了提高程式碼的清晰度和一致性,建議考慮以下兩種方式之一:1. 將所有配置值作為明確的參數從呼叫端傳入。2. 讓函式直接從 `config.js` 模組中讀取這些值,而不是透過預設參數。" "suggestion": "在 `checkRequiredEnv`、`verifyGiteaToken` 和 `verifyCommentToken` 等函式中,預設參數直接引用了從 `config.js` 匯入的常數。雖然這在功能上可行,但為了提高程式碼的清晰度和一致性,建議考慮以下兩種方式之一:1. 將所有配置值作為明確的參數從呼叫端傳入。2. 讓函式直接從 `config.js` 模組中讀取這些值,而不是透過預設參數。"
},
{
"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。"
} }
] ]
+1 -9
View File
@@ -1,9 +1 @@
[ []
{
"level": "critical",
"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 });`",
"is_new": true
}
]
+1 -1
View File
@@ -16,7 +16,7 @@
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題 4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request 5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
6. 從 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 判斷是否要跳過重跑 8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1) 9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
+38 -6
View File
@@ -1,8 +1,8 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { postComment } from './gitea.js'; import { postComment, postPullReviewComment } from './gitea.js';
import { FINDINGS_PATH } from './config.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_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
@@ -16,6 +16,26 @@ function buildTable(findings) {
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`; 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。 * 寫入 findings.json。
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。 * 預設寫到 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'); const criticals = findings.filter(f => f.is_new && f.level === 'critical');
if (criticals.length === 0) { if (criticals.length === 0) {
line('無新的嚴重問題,跳過'); line('無新的嚴重問題,跳過');
return; return;
} }
for (const f of criticals) { for (const f of criticals) {
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`; const loc = parseLocation(f.location);
await postComment(body); 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}`); ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
} }
} }
+79 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { saveFindings } from './comments.js'; import { saveFindings, parseLocation, postNewCriticalComments } from './comments.js';
import { FINDINGS_PATH } from './config.js'; import { FINDINGS_PATH } from './config.js';
describe('saveFindings', () => { 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);
});
});
+19
View File
@@ -127,3 +127,22 @@ export async function postComment(body) {
); );
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 }) {
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;
}
+26 -1
View File
@@ -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, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js'; import { getPRDiff, filterDiff, postComment, postPullReviewComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
afterEach(() => mock.restoreAll()); afterEach(() => mock.restoreAll());
@@ -57,6 +57,31 @@ describe('gitea', () => {
await assert.rejects(() => postComment('test'), /api error/); 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 () => { it('getCommitMessageBySha reads commit message from Gitea API', async () => {
let capturedUrl; let capturedUrl;
mock.method(axios, 'get', async (url) => { mock.method(axios, 'get', async (url) => {