Compare commits

..
Author SHA1 Message Date
Jeffery 3393e43877 test(gitea): 補 filterDiff 排除 node_modules/lock 測試
CI / 1. BUILD (pull_request) Successful in 1s
CI / 2. TEST (pull_request) Failing after 6m15s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 11:22:57 +08:00
Jeffery 6868dbdc8f fix(gitea): diff 過濾排除 node_modules 與 lock 檔,避免超出 LLM 輸入上限 2026-07-03 11:22:57 +08:00
Jeffery 9e70bb2245 test(llm): 補 extractMeaningfulError 測試
CI / 1. BUILD (pull_request) Successful in 1s
CI / 2. TEST (pull_request) Failing after 32s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 09:12:17 +08:00
Jeffery fa9be791ee fix(llm): 錯誤訊息改抽尾端錯誤,避免被 codex banner 洗掉 2026-07-03 09:12:17 +08:00
Jeffery 51e9568ccf test(preflight): 補 codex 模型檢查與 auth 失效測試
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 38s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-02 18:47:03 +08:00
Jeffery 1118606e97 feat(preflight): Step2 前置驗證加入 codex 模型清單檢查 2026-07-02 18:47:03 +08:00
Jeffery 64af9e9e92 fix(ci): 串接 build→test 的 VERSION 輸出
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 33s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-02 18:16:23 +08:00
7 changed files with 261 additions and 21 deletions
+4 -2
View File
@@ -8,6 +8,8 @@ jobs:
runs-on: ubuntu runs-on: ubuntu
env: env:
VERSION: "0.0.0-beta.${{ gitea.run_number }}" VERSION: "0.0.0-beta.${{ gitea.run_number }}"
outputs:
version: ${{ env.VERSION }}
steps: steps:
- name: Publishing Release - name: Publishing Release
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }} uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
@@ -20,8 +22,8 @@ jobs:
name: 2. TEST name: 2. TEST
runs-on: ubuntu runs-on: ubuntu
needs: [build] needs: [build]
outputs: env:
message: ${{ steps.composite-template.outputs.message }} VERSION: ${{ needs.build.outputs.version }}
steps: steps:
- name: Setup LLM CLI - name: Setup LLM CLI
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }} uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
+12 -6
View File
@@ -54,6 +54,9 @@ export async function getPRDiff() {
'.github/', '.github/',
'README.md', 'README.md',
'TODO.md', 'TODO.md',
'package-lock.json',
'src/package-lock.json',
'dist/',
]); ]);
} }
@@ -126,13 +129,16 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。 * @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
* @returns {string} 過濾後重新接合的 diff 文字。 * @returns {string} 過濾後重新接合的 diff 文字。
*/ */
export function filterDiff(diff, excludePrefixes) { export function filterDiff(diff, excludePrefixes = []) {
return diff.split(/(?=^diff --git )/m) return diff.split(/(?=^diff --git )/m)
.filter(block => !excludePrefixes.some(p => { .filter(block => {
const prefix = `diff --git a/${p}`; const m = block.match(/^diff --git a\/(.+?) b\//);
const singleFile = `diff --git a/${p} b/${p}`; const path = m ? m[1] : '';
return block.startsWith(prefix) || block.startsWith(singleFile); if (!path) return true;
})) // 一律排除任何深度的 node_modulesvendored 依賴不是審查對象,且會撐爆 LLM 輸入上限。
if (/(^|\/)node_modules\//.test(path)) return false;
return !excludePrefixes.some(p => path === p || path.startsWith(p));
})
.join(''); .join('');
} }
+21 -1
View File
@@ -39,10 +39,30 @@ function cliArgs({ provider, model, promptFile = null, prompt = null }) {
throw new Error(`不支援的 AI 助理 CLI: ${provider}`); throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
} }
/**
* 從 CLI 輸出中抽出「真正有意義的錯誤」。
*
* 像 codex 這類 CLI 會先印出一大段 bannerworkdir/model/...)與回顯的 prompt
* 真正的失敗原因(例如 401、token 失效、額度不足)通常落在**尾端**。直接取前段
* 會被 banner/prompt 洗掉,因此改為:先抽出看起來像錯誤的行;抽不到再退取尾段。
*
* @param {string} raw - CLI 的原始輸出(stderr 或 stdout)。
* @param {number} [limit=1000] - 回傳字串長度上限。
* @returns {string} 最能說明失敗原因的片段。
*/
export function extractMeaningfulError(raw, limit = 1000) {
const text = String(raw || '').trim();
const errorLines = text
.split('\n')
.filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|rate.?limit|quota|insufficient/i.test(l));
const picked = (errorLines.length ? errorLines.join('\n') : text).trim();
return picked.length > limit ? picked.slice(-limit) : picked;
}
function summarizeCliError(e) { function summarizeCliError(e) {
const stderr = String(e.stderr || '').trim(); const stderr = String(e.stderr || '').trim();
const stdout = String(e.stdout || '').trim(); const stdout = String(e.stdout || '').trim();
return (stderr || stdout || e.message || String(e)).slice(0, 1000); return extractMeaningfulError(stderr || stdout || e.message || String(e));
} }
async function runAssistantCLI({ provider, command, model }, prompt) { async function runAssistantCLI({ provider, command, model }, prompt) {
+78 -6
View File
@@ -1,4 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import fs from 'fs';
import os from 'os';
import { join } from 'path';
import { import {
GITEA_TOKEN, GITEA_TOKEN,
GITEA_COMMENT_TOKEN, GITEA_COMMENT_TOKEN,
@@ -11,6 +14,9 @@ import {
import { verifyRemoteAccess } from './git.js'; import { verifyRemoteAccess } from './git.js';
import { step, line, ok, error, result } from './log.js'; import { step, line, ok, error, result } from './log.js';
// codex 內部用來取得帳號可用模型清單的端點;auth 失效時會回 HTTP 401。
const CODEX_MODELS_ENDPOINT = 'https://chatgpt.com/backend-api/codex/models';
const httpsAgent = getInsecureHttpsAgent(); const httpsAgent = getInsecureHttpsAgent();
/** /**
* 組出 Gitea REST API v1 的完整網址。 * 組出 Gitea REST API v1 的完整網址。
@@ -95,22 +101,87 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
} }
} }
/**
* 讀取本機 codex 認證檔,向模型清單端點確認帳號目前可用的模型 slug。
*
* 用途:preflight 期即時分辨「auth 失效(HTTP 401)」與「模型無權限(不在清單)」,
* 不必等到 Step5 每個角色送 prompt 才神秘失敗。只讀清單、不送 prompt,不消耗生成額度。
* 所有錯誤都被攔截並轉為回傳值,不會 throw。
*
* @param {object} [deps] - 可注入相依,供測試避免真的讀檔/打網路。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
* @param {string} [deps.authPath=~/.codex/auth.json] - codex 認證檔路徑。
* @param {string} [deps.clientVersion] - 帶給端點的 client_version 查詢參數。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
* 成功回傳可用模型 slug 陣列;失敗回傳格式化錯誤訊息。
*/
export async function fetchCodexModels({
fetchImpl = fetch,
authPath = join(os.homedir(), '.codex', 'auth.json'),
clientVersion = '0.142.5',
} = {}) {
let auth;
try {
auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
} catch (e) {
return { ok: false, error: `無法讀取 codex 認證檔(${authPath}: ${e.message}` };
}
const tokens = auth.tokens || {};
if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' };
const headers = { Authorization: `Bearer ${tokens.access_token}` };
if (tokens.account_id) headers['chatgpt-account-id'] = tokens.account_id;
let resp;
try {
resp = await fetchImpl(`${CODEX_MODELS_ENDPOINT}?client_version=${clientVersion}`, { headers });
} catch (e) {
return { ok: false, error: `codex 模型清單查詢連線錯誤: ${e.message}` };
}
if (resp.status === 401) {
return { ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' };
}
if (!resp.ok) {
return { ok: false, error: `codex 模型清單查詢失敗(HTTP ${resp.status}` };
}
let data;
try {
data = await resp.json();
} catch (e) {
return { ok: false, error: `codex 模型清單回應解析失敗: ${e.message}` };
}
const slugs = Array.isArray(data.models) ? data.models.map(m => m.slug).filter(Boolean) : [];
return { ok: true, slugs };
}
/** /**
* 驗證 LLM(AI 助理 CLI)設定可用。 * 驗證 LLM(AI 助理 CLI)設定可用。
* *
* 確認目前環境可偵測到支援的 CLI且已解析出 model。實際模型可用性由 CLI * 確認目前環境可偵測到支援的 CLI 且已解析出 modelprovider 為 codex 時,
* 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度 * 額外向模型清單端點確認 auth 有效且設定的 model 在可用清單內(不送 prompt)
* @param {object} [deps] - 可注入相依,供測試。
* @param {Function} [deps.fetchCodexModelsFn=fetchCodexModels] - codex 模型清單取得函式。
* @returns {Promise< * @returns {Promise<
* {ok: true, provider: string, command: string, model: string} | * {ok: true, provider: string, command: string, model: string, models?: string[]} |
* {ok: false, provider?: string, error: string} * {ok: false, provider?: string, command?: string, model?: string, error: string}
* >} * >}
* 通過時含 provider、command model;未設定 provider 的失敗分支不含 provider 欄位 * 通過時含 provider、command、modelcodex 另含 models 清單);未設定 provider 的失敗分支不含 provider。
* @remarks 設定來源為 config.js 的 getLLMConfig()。 * @remarks 設定來源為 config.js 的 getLLMConfig()。
*/ */
export async function verifyLLM() { export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {}) {
const { provider, command, model } = getLLMConfig(); const { provider, command, model } = getLLMConfig();
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' }; if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
if (!model) return { ok: false, provider, error: '未設定 MODEL' }; if (!model) return { ok: false, provider, error: '未設定 MODEL' };
if (provider === 'codex') {
const models = await fetchCodexModelsFn();
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
if (!models.slugs.includes(model)) {
return { ok: false, provider, command, model, error: `模型 ${model} 不在 codex 可用清單: [${models.slugs.join(', ')}]` };
}
return { ok: true, provider, command, model, models: models.slugs };
}
return { ok: true, provider, command, model }; return { ok: true, provider, command, model };
} }
@@ -174,6 +245,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
return false; return false;
} }
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model}`); ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model}`);
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
result(true, '前置驗證通過'); result(true, '前置驗證通過');
return true; return true;
+17
View File
@@ -241,4 +241,21 @@ describe('filterDiff', () => {
it('returns empty string for empty diff', () => { it('returns empty string for empty diff', () => {
assert.equal(filterDiff('', ['.gitea/']), ''); assert.equal(filterDiff('', ['.gitea/']), '');
}); });
it('always drops node_modules blocks at any depth (avoids blowing the LLM input limit)', () => {
const diff = block('src/node_modules/axios/index.js')
+ block('node_modules/js-yaml/lib.js')
+ block('src/main.js');
const result = filterDiff(diff, []);
assert.ok(!result.includes('node_modules'));
assert.ok(result.includes('src/main.js'));
});
it('excludes lock files and dist via the getPRDiff prefix list', () => {
const diff = block('src/package-lock.json') + block('dist/index.js') + block('src/main.js');
const result = filterDiff(diff, ['package-lock.json', 'src/package-lock.json', 'dist/']);
assert.ok(!result.includes('package-lock.json'));
assert.ok(!result.includes('dist/'));
assert.ok(result.includes('src/main.js'));
});
}); });
+37 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises'; import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { extractBalancedJSON, extractJSONText } from '../llm.js'; import { extractBalancedJSON, extractJSONText, extractMeaningfulError } from '../llm.js';
const ENV_KEYS = [ const ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER', 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
@@ -237,3 +237,39 @@ describe('extractJSONText', () => {
assert.equal(result, 'not json at all'); assert.equal(result, 'not json at all');
}); });
}); });
describe('extractMeaningfulError', () => {
it('抽出尾端真正的錯誤,而非開頭的 codex banner/回顯 prompt', () => {
const raw = [
'OpenAI Codex v0.142.5',
'--------',
'workdir: /workspace/actions/ai-code-review',
'model: gpt-5.4-mini',
'reasoning effort: none',
'--------',
'user',
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
'ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 401 Unauthorized',
'ERROR: Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
].join('\n');
const result = extractMeaningfulError(raw);
assert.match(result, /401 Unauthorized/);
assert.match(result, /refresh token was revoked/);
assert.doesNotMatch(result, /workdir:/);
assert.doesNotMatch(result, /請依照以下系統指示/);
});
it('抽不到錯誤行時退取尾段(不取開頭)', () => {
const raw = 'A'.repeat(1200) + '\nTAIL-CONTENT';
const result = extractMeaningfulError(raw, 100);
assert.ok(result.length <= 100);
assert.match(result, /TAIL-CONTENT$/);
});
it('容錯處理空輸入', () => {
assert.equal(extractMeaningfulError(''), '');
assert.equal(extractMeaningfulError(null), '');
});
});
+92 -5
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises'; import { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js';
const LLM_ENV_KEYS = [ const LLM_ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
@@ -130,18 +130,52 @@ describe('verifyLLM', () => {
assert.match(result.error, /AI 助理 CLI/); assert.match(result.error, /AI 助理 CLI/);
}); });
it('passes when a supported assistant CLI is detected', async () => { it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => {
clearLLMEnv(); clearLLMEnv();
await installFakeCLI('codex'); await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex'; process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5-mini'; process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM(); const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.provider, 'codex'); assert.equal(result.provider, 'codex');
assert.equal(result.command, 'codex'); assert.equal(result.command, 'codex');
assert.equal(result.model, 'gpt-5-mini'); assert.equal(result.model, 'gpt-5.4-mini');
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('fails when codex auth is invalid (model list check reports 401)', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'codex');
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
});
it('fails when the configured model is not in the codex available list', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-9-imaginary';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, false);
assert.match(result.error, /不在 codex 可用清單/);
assert.match(result.error, /gpt-5\.4-mini/);
}); });
it('fails when a requested CLI is not installed', async () => { it('fails when a requested CLI is not installed', async () => {
@@ -157,6 +191,59 @@ describe('verifyLLM', () => {
}); });
describe('fetchCodexModels', () => {
async function writeAuth(json) {
tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-'));
const authPath = join(tempDir, 'auth.json');
await writeFile(authPath, JSON.stringify(json));
return authPath;
}
it('returns the model slugs on HTTP 200', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } });
let capturedUrl, capturedHeaders;
const result = await fetchCodexModels({
authPath,
fetchImpl: async (url, opts) => {
capturedUrl = url;
capturedHeaders = opts.headers;
return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) };
},
});
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] });
assert.match(capturedUrl, /client_version=/);
assert.equal(capturedHeaders['Authorization'], 'Bearer tok');
assert.equal(capturedHeaders['chatgpt-account-id'], 'acc');
});
it('reports an auth failure on HTTP 401', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'revoked' } });
const result = await fetchCodexModels({
authPath,
fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }),
});
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
});
it('fails when the auth file cannot be read', async () => {
const result = await fetchCodexModels({
authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'),
fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }),
});
assert.equal(result.ok, false);
assert.match(result.error, /無法讀取 codex 認證檔/);
});
it('fails when the auth file lacks an access_token', async () => {
const authPath = await writeAuth({ tokens: {} });
const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
assert.equal(result.ok, false);
assert.match(result.error, /缺少 tokens\.access_token/);
});
});
describe('runPreflight', () => { describe('runPreflight', () => {
function makeDeps(overrides = {}) { function makeDeps(overrides = {}) {
return { return {