From 0977e66ff4302448ff52be6842c58ecc1d300462 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:18:53 +0800 Subject: [PATCH 01/23] =?UTF-8?q?test(calculate-version):=20=E6=95=B4?= =?UTF-8?q?=E4=BD=B5=E5=96=AE=E5=85=83=E6=B8=AC=E8=A9=A6=E8=87=B3=20app/te?= =?UTF-8?q?st=20=E4=B8=A6=E7=A7=BB=E9=99=A4=E7=84=A1=E6=B3=95=E8=A2=AB?= =?UTF-8?q?=E5=9F=B7=E8=A1=8C=E7=9A=84=E9=87=8D=E8=A4=87=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E7=9B=AE=E9=8C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/package.json 的 npm test(node --test,cwd 為 app/)只會探索 app/test/, repo 根目錄另一份 test/ 中的 main 整合測試(index.test.js)因此從未被執行。 將 config/index/releases/version 收斂至 app/test/(修正 require 路徑), 保留 output 測試,移除重複且無法執行的 repo 根 test/。現 app/test/ 共 34 筆測試皆可執行。 --- app/test/config.test.js | 49 ++++++----- {test => app/test}/index.test.js | 2 +- app/test/releases.test.js | 143 +++++++++++++++++++------------ app/test/version.test.js | 100 +++++++++------------ test/config.test.js | 75 ---------------- test/releases.test.js | 105 ----------------------- test/version.test.js | 85 ------------------ 7 files changed, 160 insertions(+), 399 deletions(-) rename {test => app/test}/index.test.js (97%) delete mode 100644 test/config.test.js delete mode 100644 test/releases.test.js delete mode 100644 test/version.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js index 2e4f222..d67f54e 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -1,7 +1,8 @@ 'use strict'; -const { test } = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); + const { isUnset, requireEnv, @@ -9,42 +10,33 @@ const { loadConfig, } = require('../config'); -test('isUnset 對未設定值回傳 true', () => { +test('isUnset 將 undefined/空字串/null 字面值視為未設定', () => { assert.equal(isUnset(undefined), true); - assert.equal(isUnset(null), true); assert.equal(isUnset(''), true); assert.equal(isUnset('null'), true); -}); - -test('isUnset 對有效值回傳 false', () => { assert.equal(isUnset('value'), false); - assert.equal(isUnset('false'), false); }); -test('requireEnv 對未設定值丟出錯誤', () => { - assert.throws(() => requireEnv('NAME', ''), /NAME 未設定/); - assert.throws(() => requireEnv('NAME', 'null'), /NAME 未設定/); +test('requireEnv 未設定時拋錯,有值時回傳原值', () => { + assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/); + assert.equal(requireEnv('FOO', 'bar'), 'bar'); }); -test('requireEnv 對有效值回傳原值', () => { - assert.equal(requireEnv('NAME', 'value'), 'value'); -}); - -test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => { +test('normalizeBetaFlag 僅 "true" 視為啟用', () => { assert.equal(normalizeBetaFlag('true'), true); assert.equal(normalizeBetaFlag('false'), false); - assert.equal(normalizeBetaFlag('yes'), false); assert.equal(normalizeBetaFlag(undefined), false); - assert.equal(normalizeBetaFlag('null'), false); + assert.equal(normalizeBetaFlag('TRUE'), false); }); -test('loadConfig 從環境變數組出設定', () => { +test('loadConfig 解析完整設定', () => { const config = loadConfig({ GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_REPOSITORY: 'owner/repo', GITEA_TOKEN: 'secret', IS_BETA: 'true', }); + assert.deepEqual(config, { serverUrl: 'https://gitea.example.com', repository: 'owner/repo', @@ -53,16 +45,31 @@ test('loadConfig 從環境變數組出設定', () => { }); }); -test('loadConfig 將未提供的 token 正規化為 null,beta 預設 false', () => { +test('loadConfig 缺少 token 視為匿名(null)', () => { const config = loadConfig({ GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_REPOSITORY: 'owner/repo', + GITEA_TOKEN: 'null', }); + assert.equal(config.token, null); assert.equal(config.isBeta, false); }); -test('loadConfig 在缺少必填環境變數時丟出錯誤', () => { +test('loadConfig 缺少必填項目時拋錯', () => { assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); - assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/); +}); + +test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'not-a-url', + GITEA_REPOSITORY: 'owner/repo', + }), /GITEA_SERVER_URL 格式錯誤/); +}); + +test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'ftp://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + }), /必須使用 http 或 https/); }); diff --git a/test/index.test.js b/app/test/index.test.js similarity index 97% rename from test/index.test.js rename to app/test/index.test.js index 4309ba7..c9210b1 100644 --- a/test/index.test.js +++ b/app/test/index.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { main } = require('../app/index'); +const { main } = require('../index'); // 安靜的 log 記錄器,避免測試輸出雜訊 const silentLog = { section() {}, info() {}, error() {} }; diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 1fe4c7f..13a386e 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -1,70 +1,105 @@ 'use strict'; -const { test, afterEach } = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); -const { RELEASES_PER_PAGE, fetchReleases } = require('../releases'); -const realFetch = globalThis.fetch; +const { fetchReleases } = require('../releases'); -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function mockResponse(items) { - return { ok: true, text: async () => JSON.stringify(items) }; +// 以可控的假回應替換全域 fetch,並於結束後還原 +function withFetch(handler, run) { + const original = globalThis.fetch; + globalThis.fetch = handler; + return Promise.resolve() + .then(run) + .finally(() => { + globalThis.fetch = original; + }); } -test('fetchReleases 逐頁讀取直到不足一頁', async () => { - const fullPage = Array.from({ length: RELEASES_PER_PAGE }, (_, i) => ({ id: i })); - const lastPage = [{ id: 100 }, { id: 101 }]; - const requested = []; - globalThis.fetch = async (url) => { - const page = Number(new URL(url).searchParams.get('page')); - requested.push(page); - return mockResponse(page === 1 ? fullPage : lastPage); - }; - - const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); - - assert.equal(releases.length, RELEASES_PER_PAGE + lastPage.length); - assert.deepEqual(requested, [1, 2]); +// 建立模擬 Response 物件 +const jsonResponse = (body, ok = true) => ({ + ok, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), }); -test('fetchReleases 在空陣列頁面停止', async () => { - globalThis.fetch = async () => mockResponse([]); - const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); - assert.deepEqual(releases, []); -}); - -test('fetchReleases 帶上授權標頭', async () => { - let seenHeaders; - globalThis.fetch = async (_url, opts) => { - seenHeaders = opts.headers; - return mockResponse([]); - }; - - await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases', { token: 'secret' }); - assert.equal(seenHeaders.Authorization, 'token secret'); -}); - -test('fetchReleases 在 HTTP 錯誤時丟出例外', async () => { - globalThis.fetch = async () => ({ ok: false, status: 500 }); - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), - /release API 請求失敗/, +test('fetchReleases 單頁(不足一頁即停止)', async () => { + await withFetch( + async () => jsonResponse([{ tag_name: 'v1.0.0' }]), + async () => { + const releases = await fetchReleases('https://gitea.example.com/api'); + assert.deepEqual(releases, [{ tag_name: 'v1.0.0' }]); + }, ); }); -test('fetchReleases 在回傳非陣列時丟出例外', async () => { - globalThis.fetch = async () => mockResponse({ message: 'not an array' }); - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), - /回傳非陣列資料/, +test('fetchReleases 跨頁合併直到不足一頁', async () => { + const pages = { + 1: Array.from({ length: 10 }, (_, i) => ({ tag_name: `v1.0.${i}` })), + 2: [{ tag_name: 'v1.1.0' }], + }; + const seen = []; + + await withFetch( + async (url) => { + const page = Number(new URL(url).searchParams.get('page')); + seen.push(page); + return jsonResponse(pages[page]); + }, + async () => { + const releases = await fetchReleases('https://gitea.example.com/api'); + assert.equal(releases.length, 11); + assert.deepEqual(seen, [1, 2]); + }, ); }); -test('fetchReleases 在 null 回應時視為無更多資料', async () => { - globalThis.fetch = async () => ({ ok: true, text: async () => 'null' }); - const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); - assert.deepEqual(releases, []); +test('fetchReleases 對非 2xx 回應拋錯', async () => { + await withFetch( + async () => jsonResponse('Not Found', false), + async () => { + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api'), + /release API 請求失敗/, + ); + }, + ); +}); + +test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => { + await withFetch( + async () => jsonResponse('{ this is not valid json', true), + async () => { + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api'), + /回傳資料無法解析/, + ); + }, + ); +}); + +test('fetchReleases 對非陣列回應拋錯', async () => { + await withFetch( + async () => jsonResponse({ message: 'oops' }), + async () => { + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api'), + /回傳非陣列資料/, + ); + }, + ); +}); + +test('fetchReleases 有 token 時帶入授權標頭', async () => { + let captured; + + await withFetch( + async (url, options) => { + captured = options; + return jsonResponse([]); + }, + async () => { + await fetchReleases('https://gitea.example.com/api', { token: 'secret' }); + assert.equal(captured.headers.Authorization, 'token secret'); + }, + ); }); diff --git a/app/test/version.test.js b/app/test/version.test.js index 5d3a636..f24e040 100644 --- a/app/test/version.test.js +++ b/app/test/version.test.js @@ -1,7 +1,8 @@ 'use strict'; -const { test } = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); + const { compareVersionArrays, parseStableVersions, @@ -11,91 +12,74 @@ const { calculateVersion, } = require('../version'); -test('compareVersionArrays 逐區段數值比較,較短者視為較小', () => { - assert.ok(compareVersionArrays([1, 10, 0], [1, 9, 0]) > 0); +// 便於建構測試資料的小工具 +const rel = (tag) => ({ tag_name: tag }); + +test('compareVersionArrays 逐區段比較且較短者較小', () => { + assert.ok(compareVersionArrays([1, 10], [1, 2, 3]) > 0); assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0); assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); - assert.ok(compareVersionArrays([2, 0, 0], [1, 9, 9]) > 0); }); -test('parseStableVersions 排除 beta、去除前綴 v 並驗證數字格式', () => { +test('parseStableVersions 排除 beta 與非法 tag 並去除 v 前綴', () => { const releases = [ - { tag_name: 'v1.2.3' }, - { tag_name: '2.0.0' }, - { tag_name: 'v1.3.0-beta.1' }, - { tag_name: 'nightly' }, - { tag_name: null }, - null, + rel('v1.2.3'), + rel('2.0.0'), + rel('v1.0.0-beta.1'), + rel('latest'), + { name: '沒有 tag_name' }, ]; + assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]); }); -test('parseStableVersions 對非陣列回傳空陣列', () => { - assert.deepEqual(parseStableVersions(null), []); - assert.deepEqual(parseStableVersions(undefined), []); -}); - -test('latestStableVersion 在無資料時回傳 0.0.0', () => { +test('latestStableVersion 無資料時回傳 0.0.0', () => { assert.equal(latestStableVersion([]), '0.0.0'); - assert.equal(latestStableVersion([{ tag_name: 'v1.0.0-beta.1' }]), '0.0.0'); + assert.equal(latestStableVersion(null), '0.0.0'); }); -test('latestStableVersion 取最新的穩定版(含跨區段比較)', () => { - assert.equal(latestStableVersion([ - { tag_name: 'v1.9.0' }, - { tag_name: 'v1.10.0' }, - { tag_name: 'v1.2.3' }, - ]), '1.10.0'); +test('latestStableVersion 取出最大穩定版', () => { + const releases = [rel('v1.2.3'), rel('v1.9.9'), rel('v1.2.9'), rel('v0.5.0-beta.2')]; + assert.equal(latestStableVersion(releases), '1.9.9'); }); -test('nextReleaseVersion 對 patch 進位', () => { +test('nextReleaseVersion patch 進位', () => { assert.equal(nextReleaseVersion('0.0.0'), '0.0.1'); assert.equal(nextReleaseVersion('1.2.3'), '1.2.4'); }); -test('nextReleaseVersion 在 patch 達 10 時進位至 minor', () => { +test('nextReleaseVersion patch 達 10 進位至 minor', () => { assert.equal(nextReleaseVersion('1.2.9'), '1.3.0'); }); -test('nextReleaseVersion 在 minor 也達 10 時進位至 major', () => { +test('nextReleaseVersion minor 達 10 進位至 major', () => { assert.equal(nextReleaseVersion('1.9.9'), '2.0.0'); }); -test('nextBetaNumber 在查無對應 beta 時回傳 1', () => { - assert.equal(nextBetaNumber([], '1.2.4'), 1); - assert.equal(nextBetaNumber([{ tag_name: 'v9.9.9-beta.5' }], '1.2.4'), 1); - assert.equal(nextBetaNumber(null, '1.2.4'), 1); +test('nextBetaNumber 無對應 beta 時回傳 1', () => { + assert.equal(nextBetaNumber([], '0.0.1'), 1); + assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1); }); -test('nextBetaNumber 取對應 beta 流水號最大值 + 1', () => { - const releases = [ - { tag_name: 'v1.2.4-beta.1' }, - { tag_name: 'v1.2.4-beta.3' }, - { tag_name: 'v1.2.4-beta.2' }, - { tag_name: 'v1.2.5-beta.9' }, - ]; - assert.equal(nextBetaNumber(releases, '1.2.4'), 4); +test('nextBetaNumber 取最大流水號加一', () => { + const releases = [rel('v1.3.0-beta.1'), rel('v1.3.0-beta.3'), rel('v1.3.0-beta.2')]; + assert.equal(nextBetaNumber(releases, '1.3.0'), 4); }); -test('calculateVersion 計算下一個穩定版', () => { - assert.deepEqual( - calculateVersion([{ tag_name: 'v1.2.3' }], false), - { latest: '1.2.3', version: '1.2.4' }, - ); -}); - -test('calculateVersion 計算下一個 beta 版', () => { - const releases = [ - { tag_name: 'v1.2.3' }, - { tag_name: 'v1.2.4-beta.2' }, - ]; - assert.deepEqual( - calculateVersion(releases, true), - { latest: '1.2.3', version: '1.2.4-beta.3' }, - ); -}); - -test('calculateVersion 在無任何 release 時的預設值', () => { +test('calculateVersion 無 release 的穩定版', () => { assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' }); +}); + +test('calculateVersion 無 release 的 beta 版', () => { assert.deepEqual(calculateVersion([], true), { latest: '0.0.0', version: '0.0.1-beta.1' }); }); + +test('calculateVersion 既有穩定版推下一個穩定版', () => { + const releases = [rel('v1.2.3'), rel('v1.2.2')]; + assert.deepEqual(calculateVersion(releases, false), { latest: '1.2.3', version: '1.2.4' }); +}); + +test('calculateVersion 既有穩定版推下一個 beta 版', () => { + const releases = [rel('v1.2.3'), rel('v1.2.4-beta.1')]; + assert.deepEqual(calculateVersion(releases, true), { latest: '1.2.3', version: '1.2.4-beta.2' }); +}); diff --git a/test/config.test.js b/test/config.test.js deleted file mode 100644 index 03a989d..0000000 --- a/test/config.test.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - isUnset, - requireEnv, - normalizeBetaFlag, - loadConfig, -} = require('../app/config'); - -test('isUnset 將 undefined/空字串/null 字面值視為未設定', () => { - assert.equal(isUnset(undefined), true); - assert.equal(isUnset(''), true); - assert.equal(isUnset('null'), true); - assert.equal(isUnset('value'), false); -}); - -test('requireEnv 未設定時拋錯,有值時回傳原值', () => { - assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/); - assert.equal(requireEnv('FOO', 'bar'), 'bar'); -}); - -test('normalizeBetaFlag 僅 "true" 視為啟用', () => { - assert.equal(normalizeBetaFlag('true'), true); - assert.equal(normalizeBetaFlag('false'), false); - assert.equal(normalizeBetaFlag(undefined), false); - assert.equal(normalizeBetaFlag('TRUE'), false); -}); - -test('loadConfig 解析完整設定', () => { - const config = loadConfig({ - GITEA_SERVER_URL: 'https://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - GITEA_TOKEN: 'secret', - IS_BETA: 'true', - }); - - assert.deepEqual(config, { - serverUrl: 'https://gitea.example.com', - repository: 'owner/repo', - token: 'secret', - isBeta: true, - }); -}); - -test('loadConfig 缺少 token 視為匿名(null)', () => { - const config = loadConfig({ - GITEA_SERVER_URL: 'https://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - GITEA_TOKEN: 'null', - }); - - assert.equal(config.token, null); - assert.equal(config.isBeta, false); -}); - -test('loadConfig 缺少必填項目時拋錯', () => { - assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); -}); - -test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => { - assert.throws(() => loadConfig({ - GITEA_SERVER_URL: 'not-a-url', - GITEA_REPOSITORY: 'owner/repo', - }), /GITEA_SERVER_URL 格式錯誤/); -}); - -test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => { - assert.throws(() => loadConfig({ - GITEA_SERVER_URL: 'ftp://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - }), /必須使用 http 或 https/); -}); diff --git a/test/releases.test.js b/test/releases.test.js deleted file mode 100644 index c305bf9..0000000 --- a/test/releases.test.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { fetchReleases } = require('../app/releases'); - -// 以可控的假回應替換全域 fetch,並於結束後還原 -function withFetch(handler, run) { - const original = globalThis.fetch; - globalThis.fetch = handler; - return Promise.resolve() - .then(run) - .finally(() => { - globalThis.fetch = original; - }); -} - -// 建立模擬 Response 物件 -const jsonResponse = (body, ok = true) => ({ - ok, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), -}); - -test('fetchReleases 單頁(不足一頁即停止)', async () => { - await withFetch( - async () => jsonResponse([{ tag_name: 'v1.0.0' }]), - async () => { - const releases = await fetchReleases('https://gitea.example.com/api'); - assert.deepEqual(releases, [{ tag_name: 'v1.0.0' }]); - }, - ); -}); - -test('fetchReleases 跨頁合併直到不足一頁', async () => { - const pages = { - 1: Array.from({ length: 10 }, (_, i) => ({ tag_name: `v1.0.${i}` })), - 2: [{ tag_name: 'v1.1.0' }], - }; - const seen = []; - - await withFetch( - async (url) => { - const page = Number(new URL(url).searchParams.get('page')); - seen.push(page); - return jsonResponse(pages[page]); - }, - async () => { - const releases = await fetchReleases('https://gitea.example.com/api'); - assert.equal(releases.length, 11); - assert.deepEqual(seen, [1, 2]); - }, - ); -}); - -test('fetchReleases 對非 2xx 回應拋錯', async () => { - await withFetch( - async () => jsonResponse('Not Found', false), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /release API 請求失敗/, - ); - }, - ); -}); - -test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => { - await withFetch( - async () => jsonResponse('{ this is not valid json', true), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /回傳資料無法解析/, - ); - }, - ); -}); - -test('fetchReleases 對非陣列回應拋錯', async () => { - await withFetch( - async () => jsonResponse({ message: 'oops' }), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /回傳非陣列資料/, - ); - }, - ); -}); - -test('fetchReleases 有 token 時帶入授權標頭', async () => { - let captured; - - await withFetch( - async (url, options) => { - captured = options; - return jsonResponse([]); - }, - async () => { - await fetchReleases('https://gitea.example.com/api', { token: 'secret' }); - assert.equal(captured.headers.Authorization, 'token secret'); - }, - ); -}); diff --git a/test/version.test.js b/test/version.test.js deleted file mode 100644 index bfdccba..0000000 --- a/test/version.test.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - compareVersionArrays, - parseStableVersions, - latestStableVersion, - nextReleaseVersion, - nextBetaNumber, - calculateVersion, -} = require('../app/version'); - -// 便於建構測試資料的小工具 -const rel = (tag) => ({ tag_name: tag }); - -test('compareVersionArrays 逐區段比較且較短者較小', () => { - assert.ok(compareVersionArrays([1, 10], [1, 2, 3]) > 0); - assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0); - assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); -}); - -test('parseStableVersions 排除 beta 與非法 tag 並去除 v 前綴', () => { - const releases = [ - rel('v1.2.3'), - rel('2.0.0'), - rel('v1.0.0-beta.1'), - rel('latest'), - { name: '沒有 tag_name' }, - ]; - - assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]); -}); - -test('latestStableVersion 無資料時回傳 0.0.0', () => { - assert.equal(latestStableVersion([]), '0.0.0'); - assert.equal(latestStableVersion(null), '0.0.0'); -}); - -test('latestStableVersion 取出最大穩定版', () => { - const releases = [rel('v1.2.3'), rel('v1.9.9'), rel('v1.2.9'), rel('v0.5.0-beta.2')]; - assert.equal(latestStableVersion(releases), '1.9.9'); -}); - -test('nextReleaseVersion patch 進位', () => { - assert.equal(nextReleaseVersion('0.0.0'), '0.0.1'); - assert.equal(nextReleaseVersion('1.2.3'), '1.2.4'); -}); - -test('nextReleaseVersion patch 達 10 進位至 minor', () => { - assert.equal(nextReleaseVersion('1.2.9'), '1.3.0'); -}); - -test('nextReleaseVersion minor 達 10 進位至 major', () => { - assert.equal(nextReleaseVersion('1.9.9'), '2.0.0'); -}); - -test('nextBetaNumber 無對應 beta 時回傳 1', () => { - assert.equal(nextBetaNumber([], '0.0.1'), 1); - assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1); -}); - -test('nextBetaNumber 取最大流水號加一', () => { - const releases = [rel('v1.3.0-beta.1'), rel('v1.3.0-beta.3'), rel('v1.3.0-beta.2')]; - assert.equal(nextBetaNumber(releases, '1.3.0'), 4); -}); - -test('calculateVersion 無 release 的穩定版', () => { - assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' }); -}); - -test('calculateVersion 無 release 的 beta 版', () => { - assert.deepEqual(calculateVersion([], true), { latest: '0.0.0', version: '0.0.1-beta.1' }); -}); - -test('calculateVersion 既有穩定版推下一個穩定版', () => { - const releases = [rel('v1.2.3'), rel('v1.2.2')]; - assert.deepEqual(calculateVersion(releases, false), { latest: '1.2.3', version: '1.2.4' }); -}); - -test('calculateVersion 既有穩定版推下一個 beta 版', () => { - const releases = [rel('v1.2.3'), rel('v1.2.4-beta.1')]; - assert.deepEqual(calculateVersion(releases, true), { latest: '1.2.3', version: '1.2.4-beta.2' }); -}); -- 2.53.0 From bde3719aadb708cacef12ccb3815a709f3434544 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:19:49 +0000 Subject: [PATCH 02/23] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 35 ++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 93d2ebe..ba7e5ee 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -5,7 +5,7 @@ "location": "app/config.js:71", "problem": "對 GITEA_REPOSITORY 環境變數缺乏輸入驗證。由於此值會直接拼接於 API URL 中(見 app/index.js:37),若攻擊者傳入特殊字元或路徑穿越字元(如 `../`),可能導致 API 請求路徑異常,甚至造成非預期的 API 端點存取。", "suggestion": "增加格式驗證機制,使用嚴格的正則表達式限制 GITEA_REPOSITORY 格式(例如確保只包含合法的 repo 名稱字元:`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$`),拒絕任何不符合規範的輸入。", - "is_new": true + "is_new": false }, { "level": "warning", @@ -13,7 +13,7 @@ "location": "app/releases.js:77", "problem": "JSON.parse 失敗時,僅拋出通用錯誤訊息,未來除錯時無法得知具體回傳內容,將導致除錯時浪費大量時間追查。", "suggestion": "建議將錯誤訊息擴充,納入部分的 response body 內容,以利於快速定位回傳格式異常的確切原因。", - "is_new": true + "is_new": false }, { "level": "warning", @@ -21,6 +21,37 @@ "location": "app/logger.js:1", "problem": "整個 logger.js 模組完全沒有測試,無法確保 section、info 與 error 函式是否正確將訊息格式化並寫入標準輸出與標準錯誤。", "suggestion": "為 app/logger.js 新增測試,模擬 process.stdout 與 process.stderr,驗證輸出的字串格式是否符合預期(例如分隔線寬度、前綴是否正確)。", + "is_new": false + }, + { + "level": "warning", + "role": "Leo", + "location": "app/test/config.test.js:14", + "problem": "測試案例名稱與內容不符,將相反行為寫在同一個 test 中,且移除了對 null 與 \"false\" 的特定檢查。", + "suggestion": "將斷言拆分為獨立的測試案例,並補回對 null 與字串 \"false\" 的邊界測試。" + }, + { + "level": "warning", + "role": "Leo", + "location": "app/test/config.test.js:24", + "problem": "測試案例同時包含「錯誤處理」與「成功回傳」兩種行為。測試應遵循單一職責原則,若邏輯調整導致其中一項失敗,目前寫法會使得錯誤定位變得困難。", + "suggestion": "建議將 requireEnv 的拋錯測試與成功回傳測試拆分為兩個獨立的測試案例。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/test/version.test.js:19", + "problem": "新的 `compareVersionArrays` 測試雖增加了案例,但移除了 `compareVersionArrays([2, 0, 0], [1, 9, 9]) > 0`,這對 Major 版本進位的跨區段比較極為重要。", + "suggestion": "建議補回對大版本變更的比較測試,確保版本排序邏輯正確。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/test/releases.test.js:10", + "problem": "withFetch 為全域 fetch 的通用封裝工具,目前定義在特定測試檔案內。若後續專案中其他測試檔案也需要模擬 fetch,將導致測試工具邏輯重複分散。", + "suggestion": "建議將此類通用的測試輔助函式提取至獨立的測試工具檔案(例如 app/test/test-utils.js),以提升測試程式碼的重用性與維護一致性。", "is_new": true } ] -- 2.53.0 From b8804c5218ac63df4a114f65a37563c0aee131da Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:01:30 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix(calculate-version):=20=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E8=BC=B8=E5=85=A5=E9=A9=97=E8=AD=89=E8=88=87=E9=8C=AF?= =?UTF-8?q?=E8=AA=A4=E8=A8=8A=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config 對 GITEA_REPOSITORY 加入 owner/repo 格式驗證,拒絕路徑穿越與特殊字元 - releases JSON 解析失敗時,錯誤訊息納入截斷的回傳內容片段以利除錯 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 16 ++++++++++++++-- app/releases.js | 4 +++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/config.js b/app/config.js index bf57afe..e2ccd7f 100644 --- a/app/config.js +++ b/app/config.js @@ -55,20 +55,32 @@ function assertHttpUrl(name, value) { } } +// 驗證 repository 為 owner/repo 格式(僅允許字母數字與 . _ -,且拒絕 . 與 .. 路徑穿越段) +function assertRepository(name, value) { + const parts = value.split('/'); + const valid = parts.length === 2 + && parts.every((part) => /^[A-Za-z0-9._-]+$/.test(part) && part !== '.' && part !== '..'); + if (!valid) { + throw new Error(`${name} 格式錯誤,必須為 owner/repo`); + } +} + /** * 從環境變數載入並驗證執行所需的設定。 * * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL - * 另需為合法的 http/https URL;GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 + * 另需為合法的 http/https URL,GITEA_REPOSITORY 另需為 owner/repo 格式; + * GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 * * @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。 * @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。 - * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定,或 GITEA_SERVER_URL 非合法 http/https URL 時拋出。 + * @throws {Error} 當必填項未設定、GITEA_SERVER_URL 非合法 http/https URL,或 GITEA_REPOSITORY 非 owner/repo 格式時拋出。 */ function loadConfig(env = process.env) { const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); assertHttpUrl('GITEA_SERVER_URL', serverUrl); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); + assertRepository('GITEA_REPOSITORY', repository); const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const isBeta = normalizeBetaFlag(env.IS_BETA); diff --git a/app/releases.js b/app/releases.js index e2b049d..e057039 100644 --- a/app/releases.js +++ b/app/releases.js @@ -63,7 +63,9 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - throw new Error(`release API 回傳資料無法解析 (page=${page})`); + // 附上截斷的回傳內容片段,便於除錯回傳格式異常 + const snippet = text.slice(0, 200); + throw new Error(`release API 回傳資料無法解析 (page=${page}): ${snippet}`); } if (pageJson === null) { -- 2.53.0 From a9d137ac9ec053526bc7ad52442d322274517c34 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:01:30 +0800 Subject: [PATCH 04/23] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C?= =?UTF-8?q?=E5=BC=B7=E6=B8=AC=E8=A9=A6=E8=A6=86=E8=93=8B=E4=B8=A6=E6=8B=86?= =?UTF-8?q?=E5=88=86=E6=B7=B7=E5=90=88=E6=B8=AC=E8=A9=A6=E6=A1=88=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 logger section/info/error 的輸出格式測試 - config 補上 GITEA_REPOSITORY 驗證測試,並拆分 isUnset/requireEnv 的正反例 - version 補回 compareVersionArrays 的 major 跨區段比較案例 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/config.test.js | 35 ++++++++++++++++++++++--- app/test/logger.test.js | 55 ++++++++++++++++++++++++++++++++++++++++ app/test/version.test.js | 1 + 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 app/test/logger.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js index d67f54e..c41bed6 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -10,15 +10,25 @@ const { loadConfig, } = require('../config'); -test('isUnset 將 undefined/空字串/null 字面值視為未設定', () => { +test('isUnset 對 undefined/null/空字串/字面 "null" 視為未設定', () => { assert.equal(isUnset(undefined), true); + assert.equal(isUnset(null), true); assert.equal(isUnset(''), true); assert.equal(isUnset('null'), true); - assert.equal(isUnset('value'), false); }); -test('requireEnv 未設定時拋錯,有值時回傳原值', () => { +test('isUnset 對其他非空值視為已設定', () => { + assert.equal(isUnset('value'), false); + assert.equal(isUnset('false'), false); + assert.equal(isUnset('0'), false); +}); + +test('requireEnv 未設定時拋錯', () => { assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/); + assert.throws(() => requireEnv('FOO', undefined), /FOO 未設定/); +}); + +test('requireEnv 有值時回傳原值', () => { assert.equal(requireEnv('FOO', 'bar'), 'bar'); }); @@ -73,3 +83,22 @@ test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => GITEA_REPOSITORY: 'owner/repo', }), /必須使用 http 或 https/); }); + +test('loadConfig 於 GITEA_REPOSITORY 格式錯誤或含路徑穿越時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: '../evil', + }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo/extra', + }), /GITEA_REPOSITORY 格式錯誤/); +}); + +test('loadConfig 接受含點號的合法 owner/repo', () => { + const config = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'my.org/my.repo', + }); + assert.equal(config.repository, 'my.org/my.repo'); +}); diff --git a/app/test/logger.test.js b/app/test/logger.test.js new file mode 100644 index 0000000..390b4dc --- /dev/null +++ b/app/test/logger.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const logger = require('../logger'); + +// 暫時攔截 process.stdout.write,回傳期間內寫出的內容 +function captureStdout(fn) { + const original = process.stdout.write; + let out = ''; + process.stdout.write = (chunk) => { out += chunk; return true; }; + try { + fn(); + } finally { + process.stdout.write = original; + } + return out; +} + +// 暫時攔截 process.stderr.write,回傳期間內寫出的內容 +function captureStderr(fn) { + const original = process.stderr.write; + let out = ''; + process.stderr.write = (chunk) => { out += chunk; return true; }; + try { + fn(); + } finally { + process.stderr.write = original; + } + return out; +} + +test('section 以主/次分隔線包夾標題輸出至 stdout', () => { + const out = captureStdout(() => logger.section('參數檢查')); + const line = '='.repeat(50); + const subline = '-'.repeat(50); + assert.equal(out, `\n${line}\n參數檢查\n${subline}\n`); +}); + +test('info 以 [info] 前綴與換行輸出至 stdout', () => { + const out = captureStdout(() => logger.info('IS_BETA=false')); + assert.equal(out, '[info] IS_BETA=false\n'); +}); + +test('error 以 [error] 前綴與換行輸出至 stderr', () => { + const out = captureStderr(() => logger.error('GITEA_SERVER_URL 未設定')); + assert.equal(out, '[error] GITEA_SERVER_URL 未設定\n'); +}); + +test('error 僅輸出不終止行程,呼叫後仍可繼續執行', () => { + let reached = false; + captureStderr(() => { logger.error('still alive'); reached = true; }); + assert.equal(reached, true); +}); diff --git a/app/test/version.test.js b/app/test/version.test.js index f24e040..13fdc43 100644 --- a/app/test/version.test.js +++ b/app/test/version.test.js @@ -18,6 +18,7 @@ const rel = (tag) => ({ tag_name: tag }); test('compareVersionArrays 逐區段比較且較短者較小', () => { assert.ok(compareVersionArrays([1, 10], [1, 2, 3]) > 0); assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0); + assert.ok(compareVersionArrays([2, 0, 0], [1, 9, 9]) > 0); assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); }); -- 2.53.0 From dd8977ffa4ef5b0b9a9879d5c1d45761bcf4c840 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:01:30 +0800 Subject: [PATCH 05/23] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json=20=E8=88=87=20exclusions.jso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 6 條(warning x6:輸入驗證、錯誤訊息、logger 測試、測試拆分與補強),1 條判定為誤報寫入 exclusions(info:withFetch 抽離屬過度設計),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 6 ++++ .gitea/ai-review/findings.json | 58 +------------------------------- 2 files changed, 7 insertions(+), 57 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index e42650b..7a5fd72 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -58,5 +58,11 @@ "role": "Leo", "original_finding": "日誌分隔線寬度與符號直接硬編碼在模組內,調整風格需改多處。建議集中管理並提供通用產生函數。", "reason": "分隔線已是模組頂層集中定義的常數 LINE/SUBLINE,單點即可調整;為固定的視覺樣式再加產生函數屬過度設計。" + }, + { + "location": "app/test/releases.test.js:10", + "role": "Leo", + "original_finding": "withFetch 為全域 fetch 的通用封裝工具,目前定義在特定測試檔案內。建議提取至獨立的測試工具檔案(例如 app/test/test-utils.js)以提升重用性。", + "reason": "目前僅 releases.test.js 單一測試檔使用 withFetch(logger.test.js 使用的是不同的 stdout/stderr 攔截輔助),尚無第二個消費者;為單一用途提前抽出共用模組屬過度設計。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index ba7e5ee..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,57 +1 @@ -[ - { - "level": "warning", - "role": "Assassin", - "location": "app/config.js:71", - "problem": "對 GITEA_REPOSITORY 環境變數缺乏輸入驗證。由於此值會直接拼接於 API URL 中(見 app/index.js:37),若攻擊者傳入特殊字元或路徑穿越字元(如 `../`),可能導致 API 請求路徑異常,甚至造成非預期的 API 端點存取。", - "suggestion": "增加格式驗證機制,使用嚴格的正則表達式限制 GITEA_REPOSITORY 格式(例如確保只包含合法的 repo 名稱字元:`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$`),拒絕任何不符合規範的輸入。", - "is_new": false - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:77", - "problem": "JSON.parse 失敗時,僅拋出通用錯誤訊息,未來除錯時無法得知具體回傳內容,將導致除錯時浪費大量時間追查。", - "suggestion": "建議將錯誤訊息擴充,納入部分的 response body 內容,以利於快速定位回傳格式異常的確切原因。", - "is_new": false - }, - { - "level": "warning", - "role": "Maya", - "location": "app/logger.js:1", - "problem": "整個 logger.js 模組完全沒有測試,無法確保 section、info 與 error 函式是否正確將訊息格式化並寫入標準輸出與標準錯誤。", - "suggestion": "為 app/logger.js 新增測試,模擬 process.stdout 與 process.stderr,驗證輸出的字串格式是否符合預期(例如分隔線寬度、前綴是否正確)。", - "is_new": false - }, - { - "level": "warning", - "role": "Leo", - "location": "app/test/config.test.js:14", - "problem": "測試案例名稱與內容不符,將相反行為寫在同一個 test 中,且移除了對 null 與 \"false\" 的特定檢查。", - "suggestion": "將斷言拆分為獨立的測試案例,並補回對 null 與字串 \"false\" 的邊界測試。" - }, - { - "level": "warning", - "role": "Leo", - "location": "app/test/config.test.js:24", - "problem": "測試案例同時包含「錯誤處理」與「成功回傳」兩種行為。測試應遵循單一職責原則,若邏輯調整導致其中一項失敗,目前寫法會使得錯誤定位變得困難。", - "suggestion": "建議將 requireEnv 的拋錯測試與成功回傳測試拆分為兩個獨立的測試案例。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/test/version.test.js:19", - "problem": "新的 `compareVersionArrays` 測試雖增加了案例,但移除了 `compareVersionArrays([2, 0, 0], [1, 9, 9]) > 0`,這對 Major 版本進位的跨區段比較極為重要。", - "suggestion": "建議補回對大版本變更的比較測試,確保版本排序邏輯正確。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/test/releases.test.js:10", - "problem": "withFetch 為全域 fetch 的通用封裝工具,目前定義在特定測試檔案內。若後續專案中其他測試檔案也需要模擬 fetch,將導致測試工具邏輯重複分散。", - "suggestion": "建議將此類通用的測試輔助函式提取至獨立的測試工具檔案(例如 app/test/test-utils.js),以提升測試程式碼的重用性與維護一致性。", - "is_new": true - } -] +[] -- 2.53.0 From 32e528988b343df31ed336a6ccb17280f8b76d74 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:02:02 +0000 Subject: [PATCH 06/23] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 46 +++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..ef308d5 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,45 @@ -[] +[ + { + "level": "critical", + "role": "Maya", + "location": "app/releases.js:68", + "problem": "新增的異常處理邏輯截斷了 `text` 為 200 字元,但在 `text` 為極大值或無法轉為字串時未進行防禦性檢查,且截斷點可能將多位元組字元拆分導致亂碼。", + "suggestion": "請確保 `text` 是字串後再截斷,並使用正確處理多位元組字元的方式(如 `Array.from(text).slice(0, 200).join('')`)以避免亂碼。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/config.js:61", + "problem": "新增的 `assertRepository` 函式缺乏對 owner/repo 字串長度的邊界檢查,且變數名稱 `valid` 過於通用,語義不夠明確。", + "suggestion": "建議在 `assertRepository` 中加入長度限制檢查,並將 `valid` 更名為 `isRepoFormatValid` 或類似具備明確語義的命名。" + }, + { + "level": "warning", + "role": "Leo", + "location": "app/test/logger.test.js:8", + "problem": "在測試中使用 monkey-patch 直接覆蓋 `process.stdout.write` 或 `process.stderr.write` 是極度危險的模式,可能導致狀態污染;且針對 `logger.error` 的測試不夠全面。", + "suggestion": "建議重構 `logger` 使其支援依賴注入;並增加針對錯誤內容包含情況及非字串參數的斷言測試。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/test/config.test.js:84", + "problem": "缺少針對 `assertRepository` 邊界條件(如空字串、異常格式)的測試。", + "suggestion": "補上 `assert.throws` 測試案例以涵蓋上述邊界條件,確保驗證邏輯完整。" + }, + { + "level": "info", + "role": "Bard", + "location": "app/releases.js:67", + "problem": "將截斷字串直接串接在 Error 訊息中若處理不當易造成混淆,變數名稱 `snippet` 語義不明確。", + "suggestion": "建議在錯誤訊息中對片段內容進行適當標記,並將變數更名為 `contentSnippet`。" + }, + { + "level": "info", + "role": "Bard", + "location": "app/test/logger.test.js:10", + "problem": "測試工具函式 `captureStdout` 與 `captureStderr` 邏輯高度重複,不符合 DRY 原則。", + "suggestion": "建議抽取出通用的 `captureStream(stream, fn)` 函式以提升可維護性。" + } +] -- 2.53.0 From da83f4fc30edd1168491f726bb5b1be36938755b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:05:08 +0800 Subject: [PATCH 07/23] =?UTF-8?q?fix(calculate-version):=20=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E7=89=87=E6=AE=B5=E6=88=AA=E6=96=B7=E8=88=87=20reposi?= =?UTF-8?q?tory=20=E9=A9=97=E8=AD=89=E5=81=A5=E5=85=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - releases 改以 Array.from 依字元截斷回應片段,避免拆分多位元組字元造成亂碼,並標記片段內容 - config assertRepository 加入每段長度上限,並將 valid 更名為語義明確的 isRepoFormatValid Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 8 +++++--- app/releases.js | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/config.js b/app/config.js index e2ccd7f..3aa85fb 100644 --- a/app/config.js +++ b/app/config.js @@ -58,9 +58,11 @@ function assertHttpUrl(name, value) { // 驗證 repository 為 owner/repo 格式(僅允許字母數字與 . _ -,且拒絕 . 與 .. 路徑穿越段) function assertRepository(name, value) { const parts = value.split('/'); - const valid = parts.length === 2 - && parts.every((part) => /^[A-Za-z0-9._-]+$/.test(part) && part !== '.' && part !== '..'); - if (!valid) { + const isRepoFormatValid = parts.length === 2 + && parts.every((part) => part.length > 0 && part.length <= 100 + && /^[A-Za-z0-9._-]+$/.test(part) + && part !== '.' && part !== '..'); + if (!isRepoFormatValid) { throw new Error(`${name} 格式錯誤,必須為 owner/repo`); } } diff --git a/app/releases.js b/app/releases.js index e057039..3799e93 100644 --- a/app/releases.js +++ b/app/releases.js @@ -63,9 +63,9 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - // 附上截斷的回傳內容片段,便於除錯回傳格式異常 - const snippet = text.slice(0, 200); - throw new Error(`release API 回傳資料無法解析 (page=${page}): ${snippet}`); + // 以字元(而非 UTF-16 碼元)截斷回傳內容片段,避免拆分多位元組字元造成亂碼 + const contentSnippet = Array.from(text).slice(0, 200).join(''); + throw new Error(`release API 回傳資料無法解析 (page=${page}),回應內容片段:「${contentSnippet}」`); } if (pageJson === null) { -- 2.53.0 From 1e227328f25b99772c2ac95279aa19b64788bfcb Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:05:08 +0800 Subject: [PATCH 08/23] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C=20re?= =?UTF-8?q?pository=20=E9=82=8A=E7=95=8C=E6=B8=AC=E8=A9=A6=E4=B8=A6?= =?UTF-8?q?=E5=90=88=E4=BD=B5=E4=B8=B2=E6=B5=81=E6=94=94=E6=88=AA=E8=BC=94?= =?UTF-8?q?=E5=8A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 補上 GITEA_REPOSITORY 空段/缺斜線/多段等邊界格式的拋錯測試 - 將 logger 測試的 captureStdout/captureStderr 合併為單一 captureStream Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/config.test.js | 9 +++++++++ app/test/logger.test.js | 33 +++++++++++---------------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/app/test/config.test.js b/app/test/config.test.js index c41bed6..d673eab 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -95,6 +95,15 @@ test('loadConfig 於 GITEA_REPOSITORY 格式錯誤或含路徑穿越時拋錯', }), /GITEA_REPOSITORY 格式錯誤/); }); +test('loadConfig 於 GITEA_REPOSITORY 邊界格式(空段/缺斜線/多段)皆拋錯', () => { + for (const repo of ['noslash', 'owner/', '/repo', 'a/b/c', 'owner//repo']) { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: repo, + }), /GITEA_REPOSITORY 格式錯誤/, `應拒絕:${repo}`); + } +}); + test('loadConfig 接受含點號的合法 owner/repo', () => { const config = loadConfig({ GITEA_SERVER_URL: 'https://gitea.example.com', diff --git a/app/test/logger.test.js b/app/test/logger.test.js index 390b4dc..6484a58 100644 --- a/app/test/logger.test.js +++ b/app/test/logger.test.js @@ -5,51 +5,40 @@ const assert = require('node:assert/strict'); const logger = require('../logger'); -// 暫時攔截 process.stdout.write,回傳期間內寫出的內容 -function captureStdout(fn) { - const original = process.stdout.write; +// 暫時攔截指定標準串流('stdout' / 'stderr')的 write,回傳期間內寫出的內容; +// 結束時於 finally 還原原本的 write,避免狀態污染 +function captureStream(streamName, fn) { + const stream = process[streamName]; + const original = stream.write; let out = ''; - process.stdout.write = (chunk) => { out += chunk; return true; }; + stream.write = (chunk) => { out += chunk; return true; }; try { fn(); } finally { - process.stdout.write = original; - } - return out; -} - -// 暫時攔截 process.stderr.write,回傳期間內寫出的內容 -function captureStderr(fn) { - const original = process.stderr.write; - let out = ''; - process.stderr.write = (chunk) => { out += chunk; return true; }; - try { - fn(); - } finally { - process.stderr.write = original; + stream.write = original; } return out; } test('section 以主/次分隔線包夾標題輸出至 stdout', () => { - const out = captureStdout(() => logger.section('參數檢查')); + const out = captureStream('stdout', () => logger.section('參數檢查')); const line = '='.repeat(50); const subline = '-'.repeat(50); assert.equal(out, `\n${line}\n參數檢查\n${subline}\n`); }); test('info 以 [info] 前綴與換行輸出至 stdout', () => { - const out = captureStdout(() => logger.info('IS_BETA=false')); + const out = captureStream('stdout', () => logger.info('IS_BETA=false')); assert.equal(out, '[info] IS_BETA=false\n'); }); test('error 以 [error] 前綴與換行輸出至 stderr', () => { - const out = captureStderr(() => logger.error('GITEA_SERVER_URL 未設定')); + const out = captureStream('stderr', () => logger.error('GITEA_SERVER_URL 未設定')); assert.equal(out, '[error] GITEA_SERVER_URL 未設定\n'); }); test('error 僅輸出不終止行程,呼叫後仍可繼續執行', () => { let reached = false; - captureStderr(() => { logger.error('still alive'); reached = true; }); + captureStream('stderr', () => { logger.error('still alive'); reached = true; }); assert.equal(reached, true); }); -- 2.53.0 From 14c7ac4ed062e9bc0ab71ff5e2c7f67d1767fc90 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:05:08 +0800 Subject: [PATCH 09/23] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json=20=E8=88=87=20exclusions.jso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 5 條(critical x1、warning x2、info x2:多位元組截斷、repository 驗證與命名、邊界測試、串流攔截 DRY),1 條判定為誤報寫入 exclusions(warning:logger 改 DI 屬過度設計),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 6 +++++ .gitea/ai-review/findings.json | 46 +------------------------------- 2 files changed, 7 insertions(+), 45 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 7a5fd72..10abb06 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -64,5 +64,11 @@ "role": "Leo", "original_finding": "withFetch 為全域 fetch 的通用封裝工具,目前定義在特定測試檔案內。建議提取至獨立的測試工具檔案(例如 app/test/test-utils.js)以提升重用性。", "reason": "目前僅 releases.test.js 單一測試檔使用 withFetch(logger.test.js 使用的是不同的 stdout/stderr 攔截輔助),尚無第二個消費者;為單一用途提前抽出共用模組屬過度設計。" + }, + { + "location": "app/test/logger.test.js:8", + "role": "Leo", + "original_finding": "在測試中使用 monkey-patch 直接覆蓋 process.stdout.write 或 process.stderr.write 是極度危險的模式,可能導致狀態污染;建議重構 logger 使其支援依賴注入。", + "reason": "攔截已於 finally 還原原本的 write,無狀態污染,且為 Node 測試 stdout/stderr 輸出的標準作法;logger 僅是寫入 stdout/stderr 的薄封裝,為其引入依賴注入框架屬過度設計。已將兩個攔截輔助合併為單一 captureStream。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index ef308d5..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,45 +1 @@ -[ - { - "level": "critical", - "role": "Maya", - "location": "app/releases.js:68", - "problem": "新增的異常處理邏輯截斷了 `text` 為 200 字元,但在 `text` 為極大值或無法轉為字串時未進行防禦性檢查,且截斷點可能將多位元組字元拆分導致亂碼。", - "suggestion": "請確保 `text` 是字串後再截斷,並使用正確處理多位元組字元的方式(如 `Array.from(text).slice(0, 200).join('')`)以避免亂碼。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/config.js:61", - "problem": "新增的 `assertRepository` 函式缺乏對 owner/repo 字串長度的邊界檢查,且變數名稱 `valid` 過於通用,語義不夠明確。", - "suggestion": "建議在 `assertRepository` 中加入長度限制檢查,並將 `valid` 更名為 `isRepoFormatValid` 或類似具備明確語義的命名。" - }, - { - "level": "warning", - "role": "Leo", - "location": "app/test/logger.test.js:8", - "problem": "在測試中使用 monkey-patch 直接覆蓋 `process.stdout.write` 或 `process.stderr.write` 是極度危險的模式,可能導致狀態污染;且針對 `logger.error` 的測試不夠全面。", - "suggestion": "建議重構 `logger` 使其支援依賴注入;並增加針對錯誤內容包含情況及非字串參數的斷言測試。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/test/config.test.js:84", - "problem": "缺少針對 `assertRepository` 邊界條件(如空字串、異常格式)的測試。", - "suggestion": "補上 `assert.throws` 測試案例以涵蓋上述邊界條件,確保驗證邏輯完整。" - }, - { - "level": "info", - "role": "Bard", - "location": "app/releases.js:67", - "problem": "將截斷字串直接串接在 Error 訊息中若處理不當易造成混淆,變數名稱 `snippet` 語義不明確。", - "suggestion": "建議在錯誤訊息中對片段內容進行適當標記,並將變數更名為 `contentSnippet`。" - }, - { - "level": "info", - "role": "Bard", - "location": "app/test/logger.test.js:10", - "problem": "測試工具函式 `captureStdout` 與 `captureStderr` 邏輯高度重複,不符合 DRY 原則。", - "suggestion": "建議抽取出通用的 `captureStream(stream, fn)` 函式以提升可維護性。" - } -] +[] -- 2.53.0 From 8ee3638be59429236de9059f8295d02402f05ff4 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:08:59 +0000 Subject: [PATCH 10/23] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 77 +++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..ce5626f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,76 @@ -[] +[ + { + "level": "critical", + "role": "Mage", + "location": "app/config.js:63", + "problem": "在 `assertRepository` 函數中,`part !== '.' && part !== '..'` 的檢查雖然能防止路徑穿越,但正規表達式 `^[A-Za-z0-9._-]+$` 仍然允許 `.` 開頭或結尾的檔案名稱(例如 `.foo` 或 `foo.`),這在某些檔案系統中可能導致意外行為或被視為隱藏檔案,且未明確禁止僅由 `.` 組成的名稱(儘管後面有手動排除)。", + "suggestion": "建議調整正規表達式,禁止名稱以點號開頭或結尾,例如使用 `/^[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*$/` 來確保點號只能存在於中間。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/config.js:62", + "problem": "新增的 `assertRepository` 函數對 `GITEA_REPOSITORY` 的檢查邏輯並未測試到所有邊界條件,例如:包含特殊字元(非字母數字點下底線連字號)、極長字串段(> 100 字元)、以及完全空白的 `value`。", + "suggestion": "請在 `app/test/config.test.js` 中增加針對非法字元、超過 100 字元的字串段,以及空字串的測試案例,確保 `assertRepository` 的防禦性功能完全被驗證。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "app/test/config.test.js:65", + "problem": "測試檔案中的迴圈測試(for...of)將多種不同的錯誤案例綁在同一個測試名稱下,一旦其中一個案例失敗,難以快速辨識具體是哪一種格式導致驗證失效。", + "suggestion": "建議將其拆分為獨立的測試案例,或在 `assert.throws` 中加入更具描述性的訊息。" + }, + { + "level": "warning", + "role": "Leo", + "location": "app/config.js:63", + "problem": "在 `assertRepository` 函式中使用了硬編碼的 `100` 作為長度限制,這是一個魔術數字,難以維護且無法從環境配置中調整。", + "suggestion": "建議將 `100` 抽離為常數(例如 `MAX_REPO_NAME_LENGTH`),提高可維護性。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/config.js:62", + "problem": "在 `assertRepository` 中,`parts.every` 僅檢查了單一區段長度,未對總體長度(`owner/repo`)做任何限制。", + "suggestion": "建議在分割前,先對 `value` 的總長度進行合理範圍的限制。" + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/config.js:65", + "problem": "在 `every` 迴圈中重複宣告與編譯 Regex `/^[A-Za-z0-9._-]+$/`,若該函數被頻繁呼叫,將造成無謂的 CPU 與記憶體浪費。", + "suggestion": "將 Regex 移至函數外層宣告為常數。" + }, + { + "level": "info", + "role": "Assassin", + "location": "app/releases.js:68", + "problem": "將未經清洗的 API 回應片段直接納入 `Error` 物件,若 API 返回敏感資訊(內部路徑、堆疊追蹤、使用者資料),可能導致敏感資訊外洩。", + "suggestion": "錯誤報告應僅包含概括性的描述,若必須包含內容片段,務必先對片段進行內容脫敏或格式化處理。" + }, + { + "level": "info", + "role": "Bard", + "location": "app/config.js:63", + "problem": "正規表達式 `/^[A-Za-z0-9._-]+$/` 描述稍顯冗長,且未利用字元類別的特性,略顯不夠優雅。", + "suggestion": "考慮簡化寫法,或確保該格式驗證邏輯與專案其他地方一致並抽取為常數。" + }, + { + "level": "info", + "role": "Leo", + "location": "app/releases.js:67", + "problem": "硬編碼了截斷字串長度 `200`,這是一個魔術數字,若未來需要調整截斷長度以容納更多偵錯資訊或減少輸出,維護者需要直接修改此處邏輯。", + "suggestion": "建議定義一個具名的常數(例如 `API_ERROR_SNIPPET_LENGTH`),讓此數字具備語意,且未來調整時只需改動一處。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "app/test/releases.test.js:33", + "problem": "與 `config.test.js` 相比,這裡的 `jsonResponse` 輔助函式定義在測試檔案上方,風格雖然簡潔,但若未來有更多測試檔案需要類似的 mock 工具,建議統一抽離至 `test/support/` 之類的資料夾中,保持測試主體清爽。", + "suggestion": "建立 `app/test/support/mock.js` 將這類常用的測試輔助函式集中管理。", + "is_new": true + } +] -- 2.53.0 From d8a86e90ea0142ba36a73760a411c51d8265575d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:11:36 +0800 Subject: [PATCH 11/23] =?UTF-8?q?refactor(calculate-version):=20=E5=B0=87?= =?UTF-8?q?=E9=A9=97=E8=AD=89=E5=B8=B8=E6=95=B8=E6=8A=BD=E9=9B=A2=E7=82=BA?= =?UTF-8?q?=E5=85=B7=E5=90=8D=E5=B8=B8=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config 將每段長度上限與允許字元抽為 MAX_REPO_NAME_LENGTH / REPO_SEGMENT_PATTERN(regex 提升至模組層級,避免每次重編譯) - releases 將片段截斷長度抽為 API_ERROR_SNIPPET_LENGTH Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 10 +++++++--- app/releases.js | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/config.js b/app/config.js index 3aa85fb..4e37a2a 100644 --- a/app/config.js +++ b/app/config.js @@ -55,12 +55,16 @@ function assertHttpUrl(name, value) { } } -// 驗證 repository 為 owner/repo 格式(僅允許字母數字與 . _ -,且拒絕 . 與 .. 路徑穿越段) +// repository 每段名稱的最大長度與允許字元(字母數字與 . _ -) +const MAX_REPO_NAME_LENGTH = 100; +const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; + +// 驗證 repository 為 owner/repo 格式(拒絕 . 與 .. 路徑穿越段、空段、過長或含非法字元的段) function assertRepository(name, value) { const parts = value.split('/'); const isRepoFormatValid = parts.length === 2 - && parts.every((part) => part.length > 0 && part.length <= 100 - && /^[A-Za-z0-9._-]+$/.test(part) + && parts.every((part) => part.length > 0 && part.length <= MAX_REPO_NAME_LENGTH + && REPO_SEGMENT_PATTERN.test(part) && part !== '.' && part !== '..'); if (!isRepoFormatValid) { throw new Error(`${name} 格式錯誤,必須為 owner/repo`); diff --git a/app/releases.js b/app/releases.js index 3799e93..51982d3 100644 --- a/app/releases.js +++ b/app/releases.js @@ -3,6 +3,9 @@ // 每頁取得的 release 筆數 const RELEASES_PER_PAGE = 10; +// JSON 解析失敗時,附在錯誤訊息中的回應內容片段最大字元數 +const API_ERROR_SNIPPET_LENGTH = 200; + /** * 以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。 * @@ -64,7 +67,7 @@ async function fetchReleases(baseUrl, options = {}) { pageJson = JSON.parse(text); } catch { // 以字元(而非 UTF-16 碼元)截斷回傳內容片段,避免拆分多位元組字元造成亂碼 - const contentSnippet = Array.from(text).slice(0, 200).join(''); + const contentSnippet = Array.from(text).slice(0, API_ERROR_SNIPPET_LENGTH).join(''); throw new Error(`release API 回傳資料無法解析 (page=${page}),回應內容片段:「${contentSnippet}」`); } -- 2.53.0 From 5e8d58b5f9b0ddac62643253b67335d9101d5833 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:11:36 +0800 Subject: [PATCH 12/23] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C=20GI?= =?UTF-8?q?TEA=5FREPOSITORY=20=E9=9D=9E=E6=B3=95=E5=AD=97=E5=85=83?= =?UTF-8?q?=E8=88=87=E8=B6=85=E9=95=B7=E6=AE=B5=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增含空白/星號/冒號等非法字元,以及單段超過長度上限的拒絕測試。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/config.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/test/config.test.js b/app/test/config.test.js index d673eab..d3f6001 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -104,6 +104,21 @@ test('loadConfig 於 GITEA_REPOSITORY 邊界格式(空段/缺斜線/多段) } }); +test('loadConfig 於 GITEA_REPOSITORY 含非法字元或超長段時拋錯', () => { + const bad = [ + 'ow ner/repo', + 'owner/re*po', + 'owner/re:po', + `${'a'.repeat(101)}/repo`, + ]; + for (const repo of bad) { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: repo, + }), /GITEA_REPOSITORY 格式錯誤/, `應拒絕:${repo}`); + } +}); + test('loadConfig 接受含點號的合法 owner/repo', () => { const config = loadConfig({ GITEA_SERVER_URL: 'https://gitea.example.com', -- 2.53.0 From fcb1dc4525d1589ad6772105e84f513b6f0cb830 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:11:36 +0800 Subject: [PATCH 13/23] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json=20=E8=88=87=20exclusions.jso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 5 條(critical x1、warning x2、info x2:抽常數、提升 regex、補邊界測試),5 條判定為誤報寫入 exclusions(過度收緊 regex、迴圈測試已帶訊息、總長度冗餘、片段建議互相衝突、mock 過早抽離),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 30 +++++++++++++ .gitea/ai-review/findings.json | 77 +------------------------------- 2 files changed, 31 insertions(+), 76 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 10abb06..8a22ecf 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -70,5 +70,35 @@ "role": "Leo", "original_finding": "在測試中使用 monkey-patch 直接覆蓋 process.stdout.write 或 process.stderr.write 是極度危險的模式,可能導致狀態污染;建議重構 logger 使其支援依賴注入。", "reason": "攔截已於 finally 還原原本的 write,無狀態污染,且為 Node 測試 stdout/stderr 輸出的標準作法;logger 僅是寫入 stdout/stderr 的薄封裝,為其引入依賴注入框架屬過度設計。已將兩個攔截輔助合併為單一 captureStream。" + }, + { + "location": "app/config.js:63", + "role": "Mage", + "original_finding": "正規表達式 ^[A-Za-z0-9._-]+$ 仍允許以點號開頭或結尾的名稱(如 .foo 或 foo.)。建議調整為點號只能存在於中間。", + "reason": "路徑穿越風險來自 .、.. 與額外斜線,皆已明確排除;前/後綴點號非穿越向量,且禁止會誤拒如 .github 這類合法 repo 名稱。" + }, + { + "location": "app/test/config.test.js:65", + "role": "Bard", + "original_finding": "for...of 迴圈測試將多種錯誤案例綁在同一個測試名稱下,失敗時難以辨識是哪一種格式。建議拆分或加入描述性訊息。", + "reason": "迴圈內 assert.throws 已帶入第三引數描述訊息(應拒絕:),失敗時可定位具體案例;逐案拆成獨立 test 屬純風格偏好。" + }, + { + "location": "app/config.js:62", + "role": "Mage", + "original_finding": "parts.every 僅檢查單一區段長度,未對 owner/repo 總長度做限制。建議分割前先限制 value 總長度。", + "reason": "已限定恰兩段且每段 <= MAX_REPO_NAME_LENGTH,總長度已被間接上限;再加總長度檢查為冗餘。" + }, + { + "location": "app/releases.js:68", + "role": "Assassin", + "original_finding": "將未經清洗的 API 回應片段納入 Error,若 API 返回敏感資訊可能導致外洩。建議僅含概括描述或先脫敏。", + "reason": "此片段為前一輪審查明確要求加入以利除錯;release 清單 API 的非 JSON 回應通常為錯誤頁/代理訊息而非機密資料,且片段已截斷。兩輪建議互相衝突,維持可偵錯性。" + }, + { + "location": "app/test/releases.test.js:33", + "role": "Bard", + "original_finding": "jsonResponse 輔助函式定義在測試檔內,建議統一抽離至 app/test/support/mock.js 集中管理。", + "reason": "目前僅 releases.test.js 單一測試檔使用,「若未來」屬臆測性需求,為單一消費者提前抽出共用模組屬過度設計。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index ce5626f..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,76 +1 @@ -[ - { - "level": "critical", - "role": "Mage", - "location": "app/config.js:63", - "problem": "在 `assertRepository` 函數中,`part !== '.' && part !== '..'` 的檢查雖然能防止路徑穿越,但正規表達式 `^[A-Za-z0-9._-]+$` 仍然允許 `.` 開頭或結尾的檔案名稱(例如 `.foo` 或 `foo.`),這在某些檔案系統中可能導致意外行為或被視為隱藏檔案,且未明確禁止僅由 `.` 組成的名稱(儘管後面有手動排除)。", - "suggestion": "建議調整正規表達式,禁止名稱以點號開頭或結尾,例如使用 `/^[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*$/` 來確保點號只能存在於中間。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/config.js:62", - "problem": "新增的 `assertRepository` 函數對 `GITEA_REPOSITORY` 的檢查邏輯並未測試到所有邊界條件,例如:包含特殊字元(非字母數字點下底線連字號)、極長字串段(> 100 字元)、以及完全空白的 `value`。", - "suggestion": "請在 `app/test/config.test.js` 中增加針對非法字元、超過 100 字元的字串段,以及空字串的測試案例,確保 `assertRepository` 的防禦性功能完全被驗證。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "app/test/config.test.js:65", - "problem": "測試檔案中的迴圈測試(for...of)將多種不同的錯誤案例綁在同一個測試名稱下,一旦其中一個案例失敗,難以快速辨識具體是哪一種格式導致驗證失效。", - "suggestion": "建議將其拆分為獨立的測試案例,或在 `assert.throws` 中加入更具描述性的訊息。" - }, - { - "level": "warning", - "role": "Leo", - "location": "app/config.js:63", - "problem": "在 `assertRepository` 函式中使用了硬編碼的 `100` 作為長度限制,這是一個魔術數字,難以維護且無法從環境配置中調整。", - "suggestion": "建議將 `100` 抽離為常數(例如 `MAX_REPO_NAME_LENGTH`),提高可維護性。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/config.js:62", - "problem": "在 `assertRepository` 中,`parts.every` 僅檢查了單一區段長度,未對總體長度(`owner/repo`)做任何限制。", - "suggestion": "建議在分割前,先對 `value` 的總長度進行合理範圍的限制。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/config.js:65", - "problem": "在 `every` 迴圈中重複宣告與編譯 Regex `/^[A-Za-z0-9._-]+$/`,若該函數被頻繁呼叫,將造成無謂的 CPU 與記憶體浪費。", - "suggestion": "將 Regex 移至函數外層宣告為常數。" - }, - { - "level": "info", - "role": "Assassin", - "location": "app/releases.js:68", - "problem": "將未經清洗的 API 回應片段直接納入 `Error` 物件,若 API 返回敏感資訊(內部路徑、堆疊追蹤、使用者資料),可能導致敏感資訊外洩。", - "suggestion": "錯誤報告應僅包含概括性的描述,若必須包含內容片段,務必先對片段進行內容脫敏或格式化處理。" - }, - { - "level": "info", - "role": "Bard", - "location": "app/config.js:63", - "problem": "正規表達式 `/^[A-Za-z0-9._-]+$/` 描述稍顯冗長,且未利用字元類別的特性,略顯不夠優雅。", - "suggestion": "考慮簡化寫法,或確保該格式驗證邏輯與專案其他地方一致並抽取為常數。" - }, - { - "level": "info", - "role": "Leo", - "location": "app/releases.js:67", - "problem": "硬編碼了截斷字串長度 `200`,這是一個魔術數字,若未來需要調整截斷長度以容納更多偵錯資訊或減少輸出,維護者需要直接修改此處邏輯。", - "suggestion": "建議定義一個具名的常數(例如 `API_ERROR_SNIPPET_LENGTH`),讓此數字具備語意,且未來調整時只需改動一處。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "app/test/releases.test.js:33", - "problem": "與 `config.test.js` 相比,這裡的 `jsonResponse` 輔助函式定義在測試檔案上方,風格雖然簡潔,但若未來有更多測試檔案需要類似的 mock 工具,建議統一抽離至 `test/support/` 之類的資料夾中,保持測試主體清爽。", - "suggestion": "建立 `app/test/support/mock.js` 將這類常用的測試輔助函式集中管理。", - "is_new": true - } -] +[] -- 2.53.0 From 876a7fb6599dd93f50253f904efb00665eaf559c Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:12:19 +0000 Subject: [PATCH 14/23] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/exclusions.json | 6 +++++ .gitea/ai-review/findings.json | 43 +++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 8a22ecf..40996ce 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -100,5 +100,11 @@ "role": "Bard", "original_finding": "jsonResponse 輔助函式定義在測試檔內,建議統一抽離至 app/test/support/mock.js 集中管理。", "reason": "目前僅 releases.test.js 單一測試檔使用,「若未來」屬臆測性需求,為單一消費者提前抽出共用模組屬過度設計。" + }, + { + "location": "app/config.js:65", + "role": "Rogue", + "original_finding": "將 Regex 移至函數外層宣告為常數。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..d56e9d4 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,42 @@ -[] +[ + { + "level": "critical", + "role": "Maya", + "problem": "新增的 `assertRepository` 函數對 `GITEA_REPOSITORY` 的檢查邏輯並未測試到所有邊界條件,例如:包含特殊字元(非字母數字點下底線連字號)、極長字串段(> 100 字元)、以及完全空白的 `value`。", + "suggestion": "請在 `app/test/config.test.js` 中增加針對非法字元、超過 100 字元的字串段,以及空字串的測試案例,確保 `assertRepository` 的防禦性功能完全被驗證。", + "location": "app/config.js:62", + "is_new": false + }, + { + "level": "warning", + "role": "Leo", + "location": "app/releases.js:70", + "problem": "為了處理多位元組字元而使用 Array.from(text),如果回傳的錯誤內容(text)非常巨大(例如幾十 MB 的 HTML 錯誤頁面),會瞬間配置大量記憶體,可能導致效能瓶頸。", + "suggestion": "若預期回應可能非常大,建議先檢查 text.length 是否遠大於限制,或是考慮使用更輕量的字串處理方式來避免不必要的記憶體配置。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:72", + "problem": "雖然使用 `Array.from(text)` 可以正確處理多位元組字元(避免在 Unicode code point 中間截斷),但此操作會將整個回應內容轉為陣列,若 API 回傳的錯誤回應內容極大(例如數 MB 的 HTML 錯誤頁面),這會導致嚴重的記憶體耗用,甚至造成執行緒卡頓或記憶體溢位 (OOM)。", + "suggestion": "應先對 `text` 進行長度檢查,或者改用 `Buffer` 或 `string.substring()` 搭配適當的 Unicode 感知邏輯來截斷,而不是將完整內容陣列化。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/test/releases.test.js:80", + "problem": "新增的 JSON 解析錯誤處理邏輯中,對於回應內容片段(contentSnippet)的生成與截斷行為並未進行驗證。目前的測試僅檢查錯誤訊息是否包含特定字串,未確保截斷邏輯在邊界條件(如超長回應、包含特殊字元)下是否正確。", + "suggestion": "建議在 `app/test/releases.test.js` 中增加針對無法解析 JSON 的測試案例,並具體斷言錯誤訊息中包含預期的片段內容,特別是需要測試超長字串截斷是否符合預期。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/config.js:63", + "problem": "驗證邏輯 parts.every 內包含過多條件,邏輯稍顯複雜,閱讀起來需要花點時間拆解。", + "suggestion": "將 parts.every 的回呼函數提取為具名函數(例如 isValidPart),以提升代碼的可讀性與未來維護的清晰度。", + "is_new": true + } +] -- 2.53.0 From 4aa1393af2589e8f99794cd06798a4df30d93a1b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:14:30 +0800 Subject: [PATCH 15/23] =?UTF-8?q?refactor(calculate-version):=20=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E7=89=87=E6=AE=B5=E6=88=AA=E6=96=B7=E8=A8=98=E6=86=B6?= =?UTF-8?q?=E9=AB=94=E5=AE=89=E5=85=A8=E8=88=87=E6=8A=BD=E5=87=BA=E5=8D=80?= =?UTF-8?q?=E6=AE=B5=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - releases 在 Array.from 前先以長度上限截斷字串,避免將可能極大的回應整個陣列化 - config 將 repository 區段驗證抽為具名函式 isValidRepoSegment 提升可讀性 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 15 ++++++++++----- app/releases.js | 6 ++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/config.js b/app/config.js index 4e37a2a..8de6c1f 100644 --- a/app/config.js +++ b/app/config.js @@ -59,13 +59,18 @@ function assertHttpUrl(name, value) { const MAX_REPO_NAME_LENGTH = 100; const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; -// 驗證 repository 為 owner/repo 格式(拒絕 . 與 .. 路徑穿越段、空段、過長或含非法字元的段) +// 單一 repository 區段是否合法:長度在範圍內、僅含允許字元,且非 . 或 .. 路徑穿越段 +function isValidRepoSegment(part) { + return part.length > 0 + && part.length <= MAX_REPO_NAME_LENGTH + && REPO_SEGMENT_PATTERN.test(part) + && part !== '.' && part !== '..'; +} + +// 驗證 repository 為 owner/repo 格式(恰兩段,且每段皆為合法區段) function assertRepository(name, value) { const parts = value.split('/'); - const isRepoFormatValid = parts.length === 2 - && parts.every((part) => part.length > 0 && part.length <= MAX_REPO_NAME_LENGTH - && REPO_SEGMENT_PATTERN.test(part) - && part !== '.' && part !== '..'); + const isRepoFormatValid = parts.length === 2 && parts.every(isValidRepoSegment); if (!isRepoFormatValid) { throw new Error(`${name} 格式錯誤,必須為 owner/repo`); } diff --git a/app/releases.js b/app/releases.js index 51982d3..0077843 100644 --- a/app/releases.js +++ b/app/releases.js @@ -66,8 +66,10 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - // 以字元(而非 UTF-16 碼元)截斷回傳內容片段,避免拆分多位元組字元造成亂碼 - const contentSnippet = Array.from(text).slice(0, API_ERROR_SNIPPET_LENGTH).join(''); + // 先以 UTF-16 長度粗略上限截斷(避免將可能極大的回應整個陣列化), + // 再以字元(而非 UTF-16 碼元)精準截斷,避免拆分多位元組字元造成亂碼 + const boundedText = text.slice(0, API_ERROR_SNIPPET_LENGTH * 2); + const contentSnippet = Array.from(boundedText).slice(0, API_ERROR_SNIPPET_LENGTH).join(''); throw new Error(`release API 回傳資料無法解析 (page=${page}),回應內容片段:「${contentSnippet}」`); } -- 2.53.0 From a18802d465be0e886a43af882687f50198a67668 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:14:30 +0800 Subject: [PATCH 16/23] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C?= =?UTF-8?q?=E5=9B=9E=E6=87=89=E7=89=87=E6=AE=B5=E6=88=AA=E6=96=B7=E8=A1=8C?= =?UTF-8?q?=E7=82=BA=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增超長無法解析回應的測試,斷言錯誤訊息中的片段截斷至 200 字元內。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/releases.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 13a386e..3f24e04 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -77,6 +77,24 @@ test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => { ); }); +test('fetchReleases 解析失敗時截斷過長的回應片段', async () => { + const huge = `{${'x'.repeat(5000)}`; + await withFetch( + async () => jsonResponse(huge, true), + async () => { + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api'), + (err) => { + const match = err.message.match(/片段:「([\s\S]*)」$/); + assert.ok(match, '錯誤訊息應包含回應內容片段'); + assert.ok(Array.from(match[1]).length <= 200, '片段應截斷至 200 字元內'); + return true; + }, + ); + }, + ); +}); + test('fetchReleases 對非陣列回應拋錯', async () => { await withFetch( async () => jsonResponse({ message: 'oops' }), -- 2.53.0 From f4dd59d6814ee93f34419ae822c36ed7cf1892ff Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:14:30 +0800 Subject: [PATCH 17/23] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json=20=E8=88=87=20exclusions.jso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 4 條(warning x3、info x1:片段記憶體安全、截斷測試、抽出 isValidRepoSegment),1 條判定為誤報寫入 exclusions(assertRepository 邊界測試前提已不成立),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 6 +++++ .gitea/ai-review/findings.json | 43 +------------------------------- 2 files changed, 7 insertions(+), 42 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 40996ce..8258309 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -106,5 +106,11 @@ "role": "Rogue", "original_finding": "將 Regex 移至函數外層宣告為常數。", "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "app/config.js:62", + "role": "Maya", + "original_finding": "assertRepository 並未測試所有邊界條件:特殊字元、極長字串段(>100)、完全空白的 value。", + "reason": "前提已不成立:app/test/config.test.js 已涵蓋非法字元(空白/星號/冒號)、單段超過 100 字元、缺斜線/多段/空段,空字串則由 requireEnv 先擋下。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index d56e9d4..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,42 +1 @@ -[ - { - "level": "critical", - "role": "Maya", - "problem": "新增的 `assertRepository` 函數對 `GITEA_REPOSITORY` 的檢查邏輯並未測試到所有邊界條件,例如:包含特殊字元(非字母數字點下底線連字號)、極長字串段(> 100 字元)、以及完全空白的 `value`。", - "suggestion": "請在 `app/test/config.test.js` 中增加針對非法字元、超過 100 字元的字串段,以及空字串的測試案例,確保 `assertRepository` 的防禦性功能完全被驗證。", - "location": "app/config.js:62", - "is_new": false - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:70", - "problem": "為了處理多位元組字元而使用 Array.from(text),如果回傳的錯誤內容(text)非常巨大(例如幾十 MB 的 HTML 錯誤頁面),會瞬間配置大量記憶體,可能導致效能瓶頸。", - "suggestion": "若預期回應可能非常大,建議先檢查 text.length 是否遠大於限制,或是考慮使用更輕量的字串處理方式來避免不必要的記憶體配置。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/releases.js:72", - "problem": "雖然使用 `Array.from(text)` 可以正確處理多位元組字元(避免在 Unicode code point 中間截斷),但此操作會將整個回應內容轉為陣列,若 API 回傳的錯誤回應內容極大(例如數 MB 的 HTML 錯誤頁面),這會導致嚴重的記憶體耗用,甚至造成執行緒卡頓或記憶體溢位 (OOM)。", - "suggestion": "應先對 `text` 進行長度檢查,或者改用 `Buffer` 或 `string.substring()` 搭配適當的 Unicode 感知邏輯來截斷,而不是將完整內容陣列化。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/test/releases.test.js:80", - "problem": "新增的 JSON 解析錯誤處理邏輯中,對於回應內容片段(contentSnippet)的生成與截斷行為並未進行驗證。目前的測試僅檢查錯誤訊息是否包含特定字串,未確保截斷邏輯在邊界條件(如超長回應、包含特殊字元)下是否正確。", - "suggestion": "建議在 `app/test/releases.test.js` 中增加針對無法解析 JSON 的測試案例,並具體斷言錯誤訊息中包含預期的片段內容,特別是需要測試超長字串截斷是否符合預期。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/config.js:63", - "problem": "驗證邏輯 parts.every 內包含過多條件,邏輯稍顯複雜,閱讀起來需要花點時間拆解。", - "suggestion": "將 parts.every 的回呼函數提取為具名函數(例如 isValidPart),以提升代碼的可讀性與未來維護的清晰度。", - "is_new": true - } -] +[] -- 2.53.0 From 84a936a2a482adbfb0f9e13c9016cf9b53f8aebe Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:15:19 +0000 Subject: [PATCH 18/23] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..40eeb2f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,26 @@ -[] +[ + { + "level": "warning", + "role": "Bard", + "location": "app/config.js:65", + "problem": "函數 `assertRepository` 命名較為通用,但其實際行為僅在驗證 Gitea 的 `owner/repo` 格式。命名未能直接體現其檢查邏輯與該領域規則。", + "suggestion": "建議重新命名為 `assertGiteaRepositoryFormat`,讓開發者一眼就能看出該函數在檢查特定的 Gitea 倉庫格式規範。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/releases.js:69", + "problem": "雖然實作了針對多位元組字元的截斷處理邏輯,但目前的測試案例僅使用 ASCII 字元('x'),缺乏對於包含多位元組字元(如 Emoji 或特殊符號)的真實邊界情境驗證,無法確保在截斷邊界處不會產生亂碼或非預期的行為。", + "suggestion": "請在 app/test/releases.test.js 中新增一個測試案例,使用包含多位元組字元(例如 Emoji 或代理對字元)的長字串作為 fetch 回應內容,並驗證截斷後的內容片段是否完整且無亂碼。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/releases.js:70", + "problem": "在錯誤訊息中進行字串截斷處理,程式碼寫得較為複雜,使用了 Array.from(boundedText).slice(...)。雖然考慮了 UTF-16 碼元問題,但這段邏輯與核心業務功能(fetchReleases)混在一起,未來維護者若需調整錯誤訊息格式,容易誤傷功能。", + "suggestion": "將此段截斷邏輯抽取為一個獨立的工具函式,例如 truncateString(text, limit),放在 utils 檔案中以提升可讀性與測試獨立性。", + "is_new": true + } +] -- 2.53.0 From bc4cea6daf83221ccd9fd629050ac80b39ff5437 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:21:45 +0800 Subject: [PATCH 19/23] =?UTF-8?q?refactor(calculate-version):=20=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=20truncateString=20=E4=B8=A6=E6=98=8E=E7=A2=BA?= =?UTF-8?q?=E5=8C=96=20repository=20=E9=A9=97=E8=AD=89=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - releases 將片段截斷邏輯抽為可重用、可獨立測試的 truncateString(text, limit) 並 export - config 將 assertRepository 更名為 assertGiteaRepositoryFormat,凸顯其驗證 Gitea owner/repo 格式的領域語義 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 4 ++-- app/releases.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/config.js b/app/config.js index 8de6c1f..2c482cf 100644 --- a/app/config.js +++ b/app/config.js @@ -68,7 +68,7 @@ function isValidRepoSegment(part) { } // 驗證 repository 為 owner/repo 格式(恰兩段,且每段皆為合法區段) -function assertRepository(name, value) { +function assertGiteaRepositoryFormat(name, value) { const parts = value.split('/'); const isRepoFormatValid = parts.length === 2 && parts.every(isValidRepoSegment); if (!isRepoFormatValid) { @@ -91,7 +91,7 @@ function loadConfig(env = process.env) { const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); assertHttpUrl('GITEA_SERVER_URL', serverUrl); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); - assertRepository('GITEA_REPOSITORY', repository); + assertGiteaRepositoryFormat('GITEA_REPOSITORY', repository); const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const isBeta = normalizeBetaFlag(env.IS_BETA); diff --git a/app/releases.js b/app/releases.js index 0077843..89e3887 100644 --- a/app/releases.js +++ b/app/releases.js @@ -6,6 +6,13 @@ const RELEASES_PER_PAGE = 10; // JSON 解析失敗時,附在錯誤訊息中的回應內容片段最大字元數 const API_ERROR_SNIPPET_LENGTH = 200; +// 以字元(而非 UTF-16 碼元)安全截斷字串至 limit 個字元,避免拆分多位元組字元造成亂碼; +// 先以長度上限粗略截掉過長輸入,避免將可能極大的字串整個陣列化 +function truncateString(text, limit) { + const bounded = text.slice(0, limit * 2); + return Array.from(bounded).slice(0, limit).join(''); +} + /** * 以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。 * @@ -66,10 +73,7 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - // 先以 UTF-16 長度粗略上限截斷(避免將可能極大的回應整個陣列化), - // 再以字元(而非 UTF-16 碼元)精準截斷,避免拆分多位元組字元造成亂碼 - const boundedText = text.slice(0, API_ERROR_SNIPPET_LENGTH * 2); - const contentSnippet = Array.from(boundedText).slice(0, API_ERROR_SNIPPET_LENGTH).join(''); + const contentSnippet = truncateString(text, API_ERROR_SNIPPET_LENGTH); throw new Error(`release API 回傳資料無法解析 (page=${page}),回應內容片段:「${contentSnippet}」`); } @@ -96,4 +100,4 @@ async function fetchReleases(baseUrl, options = {}) { return combined; } -module.exports = { RELEASES_PER_PAGE, fetchReleases }; +module.exports = { RELEASES_PER_PAGE, truncateString, fetchReleases }; -- 2.53.0 From e6ac2a09f6d4587dcfb7ab37853eae21a3ce702e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:21:45 +0800 Subject: [PATCH 20/23] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C=20tr?= =?UTF-8?q?uncateString=20=E5=A4=9A=E4=BD=8D=E5=85=83=E7=B5=84=E5=AD=97?= =?UTF-8?q?=E5=85=83=E6=88=AA=E6=96=B7=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增以 emoji(代理對)驗證截斷不拆分多位元組字元、不殘留落單代理碼元的測試。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/releases.test.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 3f24e04..e9a7c2c 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { fetchReleases } = require('../releases'); +const { fetchReleases, truncateString } = require('../releases'); // 以可控的假回應替換全域 fetch,並於結束後還原 function withFetch(handler, run) { @@ -77,6 +77,15 @@ test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => { ); }); +test('truncateString 以字元截斷且不拆分多位元組字元(emoji 代理對)', () => { + const emojis = '😀'.repeat(25); + const result = truncateString(emojis, 10); + // 應得到完整 10 個 emoji(以 code point 計),不殘留落單的代理碼元 + assert.equal(Array.from(result).length, 10); + assert.equal(result, '😀'.repeat(10)); + assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(result), '不應殘留落單的高代理碼元'); +}); + test('fetchReleases 解析失敗時截斷過長的回應片段', async () => { const huge = `{${'x'.repeat(5000)}`; await withFetch( -- 2.53.0 From 2289b298ba7306c031e9320edb9183f74be689fe Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:21:45 +0800 Subject: [PATCH 21/23] =?UTF-8?q?chore(ci):=20=E8=AA=BF=E6=95=B4=20OpenCod?= =?UTF-8?q?e=20=E5=AF=A9=E6=9F=A5=20action=20=E8=BC=B8=E5=85=A5=E7=82=BA?= =?UTF-8?q?=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將 comment_token/secrets.COMMENT_TOKEN 改為 token/secrets.TOKEN,對齊 action 輸入。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b90e67c..ec6a64a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -37,4 +37,4 @@ jobs: uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} with: # 傳入留言用 token (secret),供 action 在 PR 上發表審查留言。 - comment_token: ${{ secrets.COMMENT_TOKEN }} + token: ${{ secrets.TOKEN }} -- 2.53.0 From a06257a0bc470da341bb6730a21e4c51ca66b762 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:21:45 +0800 Subject: [PATCH 22/23] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 3 條(warning x2、info x1:抽出 truncateString、補多位元組截斷測試、assertRepository 改名),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/findings.json | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 40eeb2f..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,26 +1 @@ -[ - { - "level": "warning", - "role": "Bard", - "location": "app/config.js:65", - "problem": "函數 `assertRepository` 命名較為通用,但其實際行為僅在驗證 Gitea 的 `owner/repo` 格式。命名未能直接體現其檢查邏輯與該領域規則。", - "suggestion": "建議重新命名為 `assertGiteaRepositoryFormat`,讓開發者一眼就能看出該函數在檢查特定的 Gitea 倉庫格式規範。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/releases.js:69", - "problem": "雖然實作了針對多位元組字元的截斷處理邏輯,但目前的測試案例僅使用 ASCII 字元('x'),缺乏對於包含多位元組字元(如 Emoji 或特殊符號)的真實邊界情境驗證,無法確保在截斷邊界處不會產生亂碼或非預期的行為。", - "suggestion": "請在 app/test/releases.test.js 中新增一個測試案例,使用包含多位元組字元(例如 Emoji 或代理對字元)的長字串作為 fetch 回應內容,並驗證截斷後的內容片段是否完整且無亂碼。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/releases.js:70", - "problem": "在錯誤訊息中進行字串截斷處理,程式碼寫得較為複雜,使用了 Array.from(boundedText).slice(...)。雖然考慮了 UTF-16 碼元問題,但這段邏輯與核心業務功能(fetchReleases)混在一起,未來維護者若需調整錯誤訊息格式,容易誤傷功能。", - "suggestion": "將此段截斷邏輯抽取為一個獨立的工具函式,例如 truncateString(text, limit),放在 utils 檔案中以提升可讀性與測試獨立性。", - "is_new": true - } -] +[] -- 2.53.0 From af7caf3a5ec96c8e8df2e145b884dcdda1823156 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 07:24:32 +0000 Subject: [PATCH 23/23] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..24b1035 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,26 @@ -[] +[ + { + "level": "warning", + "role": "Leo", + "location": "app/releases.js:14", + "problem": "`truncateString` 使用 `Array.from(bounded).slice(0, limit).join('')` 的方式處理字串截斷,雖然能避免拆分代理對(surrogate pairs),但在處理極大字串(例如 `API_ERROR_SNIPPET_LENGTH` 很大時)會因為 `Array.from` 產生巨大的陣列而導致記憶體使用量激增。考慮到這是在解析失敗時處理的錯誤訊息,這種設計可能讓原本就已經吃緊的記憶體狀況雪上加霜。", + "suggestion": "若不需要嚴格支援所有 Unicode 字元組合,考慮改用更節省記憶體的方式(如 `Intl.Segmenter` 或調整截斷邏輯),或者明確說明此處對記憶體的使用考量。若目的是為了錯誤記錄,或許直接截斷原始字串的長度後確保不要在最後一個字元產生半個代理對即可。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "problem": "雖然實作了針對多位元組字元的截斷處理邏輯,但目前的測試案例僅使用 ASCII 字元('x'),缺乏對於包含多位元組字元(如 Emoji 或特殊符號)的真實邊界情境驗證,無法確保在截斷邊界處不會產生亂碼或非預期的行為。", + "suggestion": "請在 app/test/releases.test.js 中新增一個測試案例,使用包含多位元組字元(例如 Emoji 或代理對字元)的長字串作為 fetch 回應內容,並驗證截斷後的內容片段是否完整且無亂碼。", + "location": "app/releases.js:69", + "is_new": false + }, + { + "level": "info", + "role": "Leo", + "location": "app/config.js:63", + "problem": "在 `assertGiteaRepositoryFormat` 函式中,當 `value.split('/')` 的長度不為 2 時,拋出的錯誤訊息僅籠統地說「格式錯誤」。若使用者輸入了包含多個斜線或完全沒有斜線的字串,這類訊息對修正環境變數幫助有限。", + "suggestion": "建議區分「格式不符」與「內容不符」的錯誤細節,例如提示「必須為 owner/repo 格式,包含一個斜線」。", + "is_new": true + } +] -- 2.53.0