Author SHA1 Message Date
jiantw83 d21e2f0e12 chore(ai-review 狀態): 轉換 findings wrapper 並加入排除
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 6m16s
CI / 3. RESULT (pull_request) Successful in 1s
2026-08-07 08:48:50 +00:00
jiantw83 409536b341 docs(ai-review 註解): 收斂註解與腳本雜訊 2026-08-07 08:48:50 +00:00
jiantw83 dcd80750ba fix(ai-review model validation): 驗證 model 並支援自動選模 2026-08-07 08:48:50 +00:00
AI Review Bot 5e9bd86bbc chore: update ai-review findings [ai-review-bot][success]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 2s
CI / 3. RESULT (pull_request) Successful in 1s
2026-08-07 07:09:39 +00:00
14 changed files with 108 additions and 49 deletions
+13
View File
@@ -0,0 +1,13 @@
[
{
"addedAt": "2026/08/07 16:47:53",
"prNumber": 4,
"reviewer": "Bard",
"severity": "警告",
"file": "readme.md",
"startLine": 3,
"endLine": 3,
"problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。",
"reason": "此專案的 README 本身就是生成式 API 參考文件,維持完整索引與連結有助於內部使用,屬於文件取捨而非功能性缺陷。"
}
]
+23
View File
@@ -0,0 +1,23 @@
{
"generatedAt": "2026/08/07 16:47:53",
"commitSha": "5e9bd86bbcb827415e25dcc1dea2fea2a4f07a58",
"prNumber": 4,
"tool": {
"name": "ai-review-bot",
"version": "unknown",
"model": "unknown"
},
"findings": [],
"excluded": [
{
"id": "F001",
"reviewer": "Bard",
"severity": "警告",
"file": "readme.md",
"startLine": 3,
"endLine": 3,
"problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。",
"suggestion": "把 README 收斂成專案摘要、安裝方式與使用入口;細部 API 文件另放獨立文件或改成可生成的 docs,避免主文件膨脹成資料堆。"
}
]
}
+1 -1
View File
@@ -9,7 +9,7 @@ inputs:
description: '操作 Gitea Commit API 的 Token' description: '操作 Gitea Commit API 的 Token'
required: false required: false
model: model:
description: '使用的 AI 模型' description: '使用的 AI 模型,僅允許英數字、點、底線與連字號'
required: false required: false
runs: runs:
using: 'docker' using: 'docker'
+1 -4
View File
@@ -1,8 +1,5 @@
#!/bin/sh #!/bin/sh
# ============================================================================ # Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
# 用途:Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
# 更新時間:2026/08/07 13:51:53
# ============================================================================
# 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行 # 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行
set -e set -e
+9 -21
View File
@@ -235,28 +235,16 @@ function toReviewComment(f) {
} }
/** /**
* 發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」,並提供多層降級機制。 * 發布單一 Gitea review,必要時會先降級成 summary review,再降級成一般 comment。
* * @param {Array<object>} findings 審查 findings。
* @param {Array<object>} findings 本次審查的完整 findings 陣列;當 `deps.summaryFindings` 或 * @param {object} [deps={}] 可注入的相依物件。
* `deps.commentFindings` 未提供時,兩者皆預設使用此參數。 * @param {Function} [deps.postReview=postPullReview] 發布整批 review 的函式。
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換,正常情境可省略)。 * @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 comment 的函式。
* @param {Function} [deps.postReview=postPullReview] 發布整批 review(含 body 與 comments)的函式。 * @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。 * @param {Array<object>} [deps.summaryFindings=findings] 用於統計的 findings 子集合。
* @param {Function} [deps.postIssue=postComment] 發布一般(非 review)comment 的函式,作為最終降級手段。 * @param {Array<object>} [deps.commentFindings=findings] 用於建立 review comments 的 findings 子集合。
* @param {Array<object>} [deps.summaryFindings=findings] 用於統計本文數字(含新舊問題)的 findings 子集合。 * @param {string} [deps.usageSection=''] 附加的使用量區塊。
* @param {Array<object>} [deps.commentFindings=findings] 用於產生 review comments 的 findings 子集合;
* 會先依 {@link bySeverity} 排序,僅新問題(`is_new !== false`)會被轉成行內 comment,
* 舊問題只計入統計、不再重複標註檔案與行數。
* @param {string} [deps.usageSection=''] 附加在統計表之後的用量/token 統計區塊;空字串時不附加。
* @returns {Promise<void>} 無回傳值。 * @returns {Promise<void>} 無回傳值。
* @remarks
* 降級順序:① 整批 `postReview`(含 comments)→ 失敗則 ② 僅 body 的 `postReview`
* (comments 為空陣列)→ 失敗則 ③ `postIssue(body)`。**注意:③ 未包在 try/catch 中**,
* 若 `postIssue` 本身失敗,例外會直接從本函式往外拋出(reject),呼叫端須自行 catch。
* 無論走到哪一步,只要走完 ①~③ 中任一步不再往下失敗,後續都會逐筆嘗試 `postInline` 補發
* 行內 comment,每筆各自失敗僅記錄 warn 並略過,不影響其他筆。
* 使用情境:CI 流程完成一輪 AI Code Review 後,呼叫一次本函式即可把整批結果發布到 Gitea PR;
* 單元測試時可透過 `deps` 注入假的 `postReview`/`postInline`/`postIssue` 以驗證各降級分支。
*/ */
export async function postFindingsReview(findings, deps = {}) { export async function postFindingsReview(findings, deps = {}) {
const { const {
+18 -6
View File
@@ -42,6 +42,16 @@ export const LLM_PROVIDER = 'cliproxyapi';
export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/;
function normalizeModelName(raw) {
const model = String(raw || '').trim();
if (!model) return { model: null, modelError: null };
if (!MODEL_NAME_RE.test(model)) {
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' };
}
return { model, modelError: null };
}
let _insecureHttpsAgent = null; let _insecureHttpsAgent = null;
/** /**
@@ -67,26 +77,28 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線), * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback, * `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供, * 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
* 則交由 CLIProxyAPI 自動選擇模型,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` * 則交由 CLIProxyAPI 自動選擇模型。若提供的名稱含非法字元,會被視為無效並於
* 作為存取金鑰(會 trim)。 * `modelError` 回報,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。
* *
* 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、 * 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、
* `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。 * `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。
* *
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }} * @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), modelError: (string|null), command: null }}
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。 * 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
*/ */
export function getLLMConfig() { export function getLLMConfig() {
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, ''); const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
const model = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || ''; const rawModel = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
const { model, modelError } = normalizeModelName(rawModel);
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim(); const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null }; if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model, modelError, command: null };
return { return {
provider: LLM_PROVIDER, provider: LLM_PROVIDER,
apiKeys: apiKey ? [apiKey] : [], apiKeys: apiKey ? [apiKey] : [],
baseURL, baseURL,
model: model || null, model,
modelError,
command: null, command: null,
}; };
} }
+6 -10
View File
@@ -411,16 +411,12 @@ function extractFileDiff(diff, file) {
} }
/** /**
* 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號, * 對缺行號的 findings 重新詢問原角色補上行號,成功時會就地更新 `location`。
* 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈); * @param {Array<object>} findings findings 陣列。
* 成功則直接修改(mutate)該 finding 的 location 為 `檔案:行號`,否則保留原檔名不變。 * @param {string} diff 完整 unified diff。
* 各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 concurrency。 * @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps]
* * 測試用依賴注入。
* @param {Array<object>} findings - findings 陣列;缺行號且有檔名者會被就地修改 location(mutate),其餘不受影響。 * @returns {Promise<Array<object>>} 與傳入相同參照的 findings 陣列。
* @param {string} diff - 完整 unified diff,用於擷取各檔案對應區段作為定位依據。
* @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps] - 依賴注入(利於測試):
* chatFn 預設 chatJSON;getRole 預設 loadRole;maxAttempts 預設 3(MAX_LOCATE_ATTEMPTS);concurrency 預設 LLM_CONCURRENCY。
* @returns {Promise<Array<object>>} 與傳入 findings 相同參照的陣列(部分項目的 location 已被就地修改)。
*/ */
export async function resolveMissingLineNumbers(findings, diff, deps = {}) { export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps; const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
+2 -1
View File
@@ -209,8 +209,9 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
*/ */
export async function chat(systemPrompt, userContent) { export async function chat(systemPrompt, userContent) {
const cfg = getLLMConfig(); const cfg = getLLMConfig();
const { provider, baseURL, model } = cfg; const { provider, baseURL, model, modelError } = cfg;
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API'); if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
if (modelError) throw new Error(modelError);
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`); line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
+1 -1
View File
@@ -6,7 +6,7 @@
* @returns {string} 例如 `2026/08/07 12:39:43`。 * @returns {string} 例如 `2026/08/07 12:39:43`。
*/ */
function formatTimestamp(date = new Date()) { function formatTimestamp(date = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', { const parts = new Intl.DateTimeFormat('zh-TW', {
timeZone: 'Asia/Taipei', timeZone: 'Asia/Taipei',
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
-3
View File
@@ -53,9 +53,6 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、 * 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。 * Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
* *
* 目前程式碼中有 3 個 exit 1 呼叫點(未設定 CLIProxyAPI、取 diff 失敗、所有角色分析皆失敗)
* 退出前未呼叫 `section('Pipeline 結束')`,與其餘 exit 點不一致,會少一行收尾分隔線,
* 是否為刻意設計尚需人工確認。
*/ */
export async function main() { export async function main() {
section('AI Code Review Pipeline'); section('AI Code Review Pipeline');
+2 -1
View File
@@ -177,8 +177,9 @@ export async function fetchLLMModels({
* @remarks 設定來源為 config.js 的 getLLMConfig()。 * @remarks 設定來源為 config.js 的 getLLMConfig()。
*/ */
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) { export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
const { provider, command, model } = getLLMConfig(); const { provider, command, model, modelError } = getLLMConfig();
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' }; if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
if (modelError) return { ok: false, provider, command, model, error: modelError };
if (provider === 'cliproxyapi') { if (provider === 'cliproxyapi') {
const models = await fetchLLMModelsFn(); const models = await fetchLLMModelsFn();
+10
View File
@@ -62,6 +62,16 @@ describe('getLLMConfig', () => {
assert.equal(cfg.model, 'gpt-5.4-mini'); assert.equal(cfg.model, 'gpt-5.4-mini');
}); });
it('rejects invalid model names', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
const cfg = getLLMConfig();
assert.equal(cfg.model, null);
assert.match(cfg.modelError, /無效的 model 參數/);
});
it('returns null provider when CLI_PROXY_API is missing', () => { it('returns null provider when CLI_PROXY_API is missing', () => {
process.env.MODEL = 'gpt-5.5'; process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig(); const cfg = getLLMConfig();
+7
View File
@@ -88,6 +88,13 @@ describe('chat - CLIProxyAPI', async () => {
await assert.rejects(() => chat('sys', 'user'), /401/); await assert.rejects(() => chat('sys', 'user'), /401/);
await assert.rejects(() => chat('sys', 'user'), /access token revoked/); await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
}); });
it('throws when the configured model name is invalid', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/);
});
}); });
describe('chatJSON', async () => { describe('chatJSON', async () => {
+14
View File
@@ -208,6 +208,20 @@ describe('verifyLLM', () => {
assert.match(result.error, /不在 CLIProxyAPI 可用清單/); assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
assert.match(result.error, /gpt-9-imaginary/); assert.match(result.error, /gpt-9-imaginary/);
}); });
it('fails when the configured model name is invalid', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5'] }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'cliproxyapi');
assert.match(result.error, /無效的 model 參數/);
});
}); });
describe('runPreflight', () => { describe('runPreflight', () => {