From c699fe71975d796af392de8afb1efd593c626c18 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 12:25:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=85=B3=E6=9C=AC=E5=A5=91=E7=B4=84):=20?= =?UTF-8?q?=E9=95=B7=E8=BC=B8=E5=87=BA=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=88=AA?= =?UTF-8?q?=E6=96=B7=EF=BC=8C=E6=9F=A5=E9=87=8D=E7=BF=BB=E9=A0=81=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process.stdout.write 之後立刻 process.exit 會截斷輸出——stdout 接到 pipe 時 寫入是非同步的。改為等 write 的 callback 回來再退出。實測舊寫法在約 83KB 處 被切斷,新增的回歸測試以 8000 筆標籤覆蓋這條路徑。 findIssueByTitle 的翻頁原本沒有上限,Gitea 若持續回滿一頁就會無限打下去。 加上 200 頁上限,超過即以 DEDUPE_LIMIT 報錯而非無聲回 null——無聲回 null 會 讓呼叫端把既有議題再建一次,正好是冪等查重要防的事。 parseFlags 取值時不再於三元運算式內遞增迴圈變數,改為獨立敘述。 測試工具的 maxBuffer 調高到 64MB:預設 1MB 會在長輸出時砍掉子行程,那是測試 工具的限制而非腳本的問題。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib.js | 28 ++++++++++++++++++++-------- test/helpers/run-script.js | 3 ++- test/labels-list.test.js | 19 +++++++++++++++++++ test/lib-exports.test.js | 14 ++++++++++++++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/scripts/lib.js b/scripts/lib.js index da82456..df7b46e 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -72,7 +72,8 @@ export function parseFlags(argv, spec = {}) { flags[name] = true; continue; } - const value = eq === -1 ? argv[(i += 1)] : arg.slice(eq + 1); + if (eq === -1) i += 1; + const value = eq === -1 ? argv[i] : arg.slice(eq + 1); if (value === undefined || value.startsWith('--')) { throw new ScriptError('MISSING_FLAG', `--${name} 需要一個值`); } @@ -110,18 +111,21 @@ export function parseRepo(value) { export async function main(run) { try { const data = await run(); - write({ ok: true, data }); - process.exit(0); + write({ ok: true, data }, 0); } catch (error) { const code = error instanceof ScriptError ? error.code : 'UNEXPECTED'; const message = error?.message ?? String(error); - write({ ok: false, error: { code, message } }); - process.exit(1); + write({ ok: false, error: { code, message } }, 1); } } -function write(payload) { - process.stdout.write(`${JSON.stringify(payload)}\n`); +/** + * 印出結果後才結束行程。 + * stdout 接到 pipe 時寫入是非同步的,直接 process.exit 會截斷長輸出, + * 所以要等 write 的 callback 回來再退出。 + */ +function write(payload, exitCode) { + process.stdout.write(`${JSON.stringify(payload)}\n`, () => process.exit(exitCode)); } // ── 認證來源 ─────────────────────────────────────────────────────── @@ -427,8 +431,11 @@ function checkTimeTracker(info) { export async function findIssueByTitle(login, repo, title) { const wanted = title.trim(); const pageSize = 50; + // 翻頁上限。查不完就停手並報錯,不能無聲回 null——那會讓呼叫端把既有議題再建一次, + // 正好是冪等查重要防的事。 + const maxPages = 200; - for (let page = 1; ; page += 1) { + for (let page = 1; page <= maxPages; page += 1) { const response = await giteaRequest(login, 'GET', `/repos/${repo}/issues`, { query: { state: 'all', limit: pageSize, page }, }); @@ -437,4 +444,9 @@ export async function findIssueByTitle(login, repo, title) { if (hit) return hit; if (issues.length < pageSize) return null; } + + throw new ScriptError( + 'DEDUPE_LIMIT', + `翻完 ${maxPages * pageSize} 筆議題仍未查完 ${repo},無法確認「${wanted}」是否已存在;請縮小範圍或手動確認`, + ); } diff --git a/test/helpers/run-script.js b/test/helpers/run-script.js index ad1b578..d3f1f13 100644 --- a/test/helpers/run-script.js +++ b/test/helpers/run-script.js @@ -33,7 +33,8 @@ export function runScript(name, args = [], opts = {}) { execFile( process.execPath, [join(repoRoot, 'scripts', name), ...args], - { cwd, env: childEnv }, + // 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題 + { cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { resolve({ code: typeof error?.code === 'number' ? error.code : error ? 1 : 0, diff --git a/test/labels-list.test.js b/test/labels-list.test.js index 86d1362..5e54b67 100644 --- a/test/labels-list.test.js +++ b/test/labels-list.test.js @@ -99,3 +99,22 @@ test('Gitea 回非預期狀態碼時,帶著狀態碼失敗而不是硬吞', as assert.equal(json.error.code, 'HTTP_ERROR'); assert.match(json.error.message, /500/); }); + +test('大量標籤不會被截斷:輸出仍是完整且可解析的單行 JSON', async (t) => { + // 回歸測試:stdout 接到 pipe 時寫入是非同步的,寫完立刻 process.exit 會截斷長輸出 + const many = Array.from({ length: 8000 }, (_, i) => ({ + id: i, + name: `label-${i}`, + color: 'ffffff', + description: '為了把輸出撐大到足以觸發截斷的敘述文字'.repeat(2), + })); + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/labels`]: { status: 200, body: many }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 0); + assert.equal(json.data.count, 8000); + assert.equal(json.data.labels.at(-1).name, 'label-7999'); +}); diff --git a/test/lib-exports.test.js b/test/lib-exports.test.js index be643a8..2907af0 100644 --- a/test/lib-exports.test.js +++ b/test/lib-exports.test.js @@ -111,3 +111,17 @@ test('plugin 根由自身檔案位置回推,templates 與 references 都在底 assert.ok(existsSync(templatesDir())); assert.ok(existsSync(referencesDir())); }); + +test('查重:翻頁有上限,查不完時報錯而不是無聲回 null', async (t) => { + // 無聲回 null 會讓呼叫端把既有議題再建一次,正好是查重要防的事 + const full = Array.from({ length: 50 }, (_, i) => ({ number: i, title: `不相干的議題 ${i}` })); + const stub = await startStubGitea({ + [`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: full }, + }); + t.after(() => stub.close()); + + await assert.rejects( + () => findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '永遠找不到的標題'), + (error) => error.code === 'DEDUPE_LIMIT', + ); +});