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
4 changed files with 87 additions and 8 deletions
+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) {
+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), '');
});
});