From b1aa8730a26e8d4fa8b83a3bad90ab8d5bc92a15 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:35:19 +0800 Subject: [PATCH 01/43] =?UTF-8?q?refactor(release-cleanup):=20=E5=B0=87?= =?UTF-8?q?=E6=B8=85=E7=90=86=E9=82=8F=E8=BC=AF=E7=94=B1=20bash=20?= =?UTF-8?q?=E6=94=B9=E5=AF=AB=E7=82=BA=20Node.js=20=E6=A8=A1=E7=B5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依功能分組為 logger/validate/config/gitea-client/releases/tags 模組, entrypoint.sh 改為呼叫 node /app/index.js,Dockerfile 改用 node:20-alpine 基底。 對外行為(清理舊 release 與未指定 release 的 tag)維持不變。 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 20 ++++- app/config.js | 44 +++++++++++ app/gitea-client.js | 53 +++++++++++++ app/index.js | 22 ++++++ app/logger.js | 53 +++++++++++++ app/package.json | 15 ++++ app/releases.js | 60 ++++++++++++++ app/tags.js | 66 ++++++++++++++++ app/validate.js | 36 +++++++++ entrypoint.sh | 187 +++----------------------------------------- 10 files changed, 377 insertions(+), 179 deletions(-) create mode 100644 app/config.js create mode 100644 app/gitea-client.js create mode 100644 app/index.js create mode 100644 app/logger.js create mode 100644 app/package.json create mode 100644 app/releases.js create mode 100644 app/tags.js create mode 100644 app/validate.js diff --git a/Dockerfile b/Dockerfile index 0a80edf..2984be7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,22 @@ -FROM alpine:latest +# ============================================================================= +# 用途: 建置 release-cleanup Action 的容器映像。 +# 以 Node.js 執行環境打包 /app 下的 Node.js 程式 (專案已從 bash 改寫為 Node.js), +# 並透過 entrypoint.sh 啟動。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= -# 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash curl jq +# 基底映像:Node.js 20 的 Alpine 版本 (體積小)。 +FROM node:20-alpine +# 複製 Node.js 應用程式(不含測試) +# 先單獨複製 package.json,再複製所有 *.js;有利於 Docker layer 快取。 +COPY app/package.json /app/package.json +COPY app/*.js /app/ + +# 複製容器進入點腳本至根目錄。 COPY entrypoint.sh /entrypoint.sh - +# 賦予進入點腳本可執行權限,否則 ENTRYPOINT 無法執行。 RUN chmod +x /entrypoint.sh +# 設定容器啟動時執行的進入點 (內部會 exec node /app/index.js)。 ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..eaf8de4 --- /dev/null +++ b/app/config.js @@ -0,0 +1,44 @@ +// 讀取並驗證環境變數,組出後續流程所需的設定物件。 +// 對應原本 entrypoint.sh 的「參數檢查」區段。 + +import { isEmptyOrNull, requireValue, requireInteger } from './validate.js' +import { section, info, warn } from './logger.js' + +/** + * 從環境變數載入設定並完成驗證。 + * @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 process.env + * @returns 包含 API 位址、token、保留數量等資訊的設定物件 + */ +export function loadConfig(env = process.env) { + section('參數檢查') + + const serverUrl = env.GITEA_SERVER_URL + const repository = env.GITEA_REPOSITORY + const keepCountRaw = env.KEEP_COUNT + const token = env.GITEA_TOKEN + + info(`GITEA_SERVER_URL=${serverUrl}`) + requireValue('GITEA_SERVER_URL', serverUrl) + + info(`GITEA_REPOSITORY=${repository}`) + requireValue('GITEA_REPOSITORY', repository) + + info(`KEEP_COUNT=${keepCountRaw}`) + requireValue('KEEP_COUNT', keepCountRaw) + requireInteger('KEEP_COUNT', keepCountRaw) + + if (isEmptyOrNull(token)) { + warn('GITEA_TOKEN is empty; release API calls will be anonymous') + } else { + info('GITEA_TOKEN=[redacted]') + } + + return { + serverUrl, + repository, + token: isEmptyOrNull(token) ? null : token, + keepCount: Number(keepCountRaw), + releaseApiUrl: `${serverUrl}/api/v1/repos/${repository}/releases`, + tagApiUrl: `${serverUrl}/api/v1/repos/${repository}/tags`, + } +} diff --git a/app/gitea-client.js b/app/gitea-client.js new file mode 100644 index 0000000..bb810f3 --- /dev/null +++ b/app/gitea-client.js @@ -0,0 +1,53 @@ +// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 +// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 + +export class GiteaClient { + /** + * @param {{ token?: string | null }} options 認證設定;有 token 時帶上 Authorization 標頭 + */ + constructor({ token = null } = {}) { + this.headers = {} + if (token) { + this.headers.Authorization = `token ${token}` + } + } + + /** + * 逐頁讀取分頁式清單 API,直到回傳空陣列為止,合併成單一陣列。 + * @param {string} baseUrl 不含 query string 的 API 位址 + * @returns {Promise} 所有頁面合併後的項目 + */ + async fetchAllPages(baseUrl) { + const all = [] + let page = 1 + + while (true) { + const url = `${baseUrl}?page=${page}` + const res = await fetch(url, { headers: this.headers }) + + if (!res.ok) { + throw new Error(`GET ${url} failed: HTTP ${res.status}`) + } + + const items = await res.json() + if (!Array.isArray(items) || items.length === 0) { + break + } + + all.push(...items) + page += 1 + } + + return all + } + + /** + * 對指定資源發出 DELETE 請求。 + * @param {string} url 目標資源位址 + * @returns {Promise} HTTP 狀態碼 + */ + async deleteResource(url) { + const res = await fetch(url, { method: 'DELETE', headers: this.headers }) + return res.status + } +} diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..fceefc1 --- /dev/null +++ b/app/index.js @@ -0,0 +1,22 @@ +// 進入點:載入設定、建立 Gitea 客戶端,依序清理舊成品與孤立 tag。 + +import { loadConfig } from './config.js' +import { GiteaClient } from './gitea-client.js' +import { cleanupReleases } from './releases.js' +import { cleanupOrphanTags } from './tags.js' +import { separator, fail } from './logger.js' + +async function main() { + const config = loadConfig() + const client = new GiteaClient({ token: config.token }) + + await cleanupReleases(client, config) + await cleanupOrphanTags(client, config) + + separator() +} + +main().catch((error) => { + fail(error.message) + process.exit(1) +}) diff --git a/app/logger.js b/app/logger.js new file mode 100644 index 0000000..4db97f1 --- /dev/null +++ b/app/logger.js @@ -0,0 +1,53 @@ +// 統一的主控台輸出格式,對應原本 entrypoint.sh 的 separator/section/info/... 等函式。 + +const LINE = '==================================================' +const SUBLINE = '--------------------------------------------------' + +/** + * 在前後換行的情況下輸出一條等號分隔線至 stdout,用於視覺上區隔不同階段的輸出。 + */ +export function separator() { + process.stdout.write(`\n${LINE}\n`) +} + +/** + * 輸出一個區段標題:先印分隔線,再印標題文字與一條虛線,用於標示流程進入新階段。 + * @param {string} title 區段標題文字 + */ +export function section(title) { + separator() + process.stdout.write(`${title}\n`) + process.stdout.write(`${SUBLINE}\n`) +} + +/** + * 以 `[INFO]` 前綴輸出一般資訊訊息至 stdout。 + * @param {string} message 訊息內容 + */ +export function info(message) { + process.stdout.write(`[INFO] ${message}\n`) +} + +/** + * 以 `[OK]` 前綴輸出成功訊息至 stdout(前綴補空白以與其他標籤對齊)。 + * @param {string} message 訊息內容 + */ +export function success(message) { + process.stdout.write(`[OK] ${message}\n`) +} + +/** + * 以 `[WARN]` 前綴輸出警告訊息;為與一般輸出同流,仍寫入 stdout。 + * @param {string} message 訊息內容 + */ +export function warn(message) { + process.stdout.write(`[WARN] ${message}\n`) +} + +/** + * 以 `[ERR]` 前綴輸出錯誤訊息至 stderr(唯一寫入 stderr 的輸出函式)。 + * @param {string} message 訊息內容 + */ +export function fail(message) { + process.stderr.write(`[ERR] ${message}\n`) +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..5e6849c --- /dev/null +++ b/app/package.json @@ -0,0 +1,15 @@ +{ + "name": "release-cleanup", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "清理 Gitea 舊版本成品與未指定 release 的 tag", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "node --test" + }, + "engines": { + "node": ">=18" + } +} diff --git a/app/releases.js b/app/releases.js new file mode 100644 index 0000000..418ecbd --- /dev/null +++ b/app/releases.js @@ -0,0 +1,60 @@ +// 清理舊版本成品的功能模組。 +// 對應原本 entrypoint.sh 的「取得成品資訊」與「刪除舊版本成品」區段。 + +import { section, info, success, fail, warn } from './logger.js' +import { isEmptyOrNull } from './validate.js' + +/** + * 依建立時間由新到舊排序,保留最新的 keepCount 筆,回傳其餘待刪除的成品。 + * 純函式,方便單元測試。 + * @param {any[]} releases 成品清單 + * @param {number} keepCount 要保留的筆數 + * @returns {any[]} 需要刪除的成品(較舊者) + */ +export function selectReleasesToDelete(releases, keepCount) { + const sorted = [...releases].sort( + (a, b) => new Date(b.created_at) - new Date(a.created_at), + ) + return sorted.slice(keepCount) +} + +/** + * 讀取成品清單,刪除超出保留數量的舊版本成品。 + * @param {import('./gitea-client.js').GiteaClient} client + * @param {ReturnType} config + */ +export async function cleanupReleases(client, config) { + section('取得成品資訊') + info(`GET ${config.releaseApiUrl}`) + + const releases = await client.fetchAllPages(config.releaseApiUrl) + info(`RELEASE_COUNT=${releases.length}`) + info(`KEEP_COUNT=${config.keepCount}`) + + if (releases.length <= config.keepCount) { + success('沒有需要清理的舊版本成品') + return + } + + section('刪除舊版本成品') + const toDelete = selectReleasesToDelete(releases, config.keepCount) + + for (const release of toDelete) { + const { id, tag_name: tag, name } = release + + if (isEmptyOrNull(id)) { + warn(`略過沒有 id 的成品: ${tag} (${name})`) + continue + } + + const url = `${config.releaseApiUrl}/${id}` + info(`DELETE ${tag} (${name})`) + + const code = await client.deleteResource(url) + if (code === 204) { + success(`成功刪除: ${tag} (${name})`) + } else { + fail(`刪除失敗: ${tag} (${name}), HTTP ${code}`) + } + } +} diff --git a/app/tags.js b/app/tags.js new file mode 100644 index 0000000..230de91 --- /dev/null +++ b/app/tags.js @@ -0,0 +1,66 @@ +// 清理未指定 release 的 tag 的功能模組。 +// 對應原本 entrypoint.sh 的「刪除未指定 release 的 tag」區段。 + +import { section, info, success, fail, warn } from './logger.js' +import { isEmptyOrNull } from './validate.js' + +/** + * 將 tag 分類為保留、刪除或略過(無名稱)。 + * 仍被任一 release 指定的 tag 予以保留,其餘視為孤立 tag 待刪除。 + * 純函式,方便單元測試。 + * @param {any[]} tags tag 清單 + * @param {Iterable} releaseTagNames 仍被 release 指定的 tag 名稱 + * @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} + */ +export function categorizeTags(tags, releaseTagNames) { + const keep = new Set(releaseTagNames) + + return tags.map((tag) => { + if (isEmptyOrNull(tag.name)) { + return { tag, action: 'skip' } + } + if (keep.has(tag.name)) { + return { tag, action: 'keep' } + } + return { tag, action: 'delete' } + }) +} + +/** + * 重新讀取成品清單以取得仍被指定的 tag,再刪除未指定 release 的孤立 tag。 + * @param {import('./gitea-client.js').GiteaClient} client + * @param {ReturnType} config + */ +export async function cleanupOrphanTags(client, config) { + section('刪除未指定 release 的 tag') + + // 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品 + const currentReleases = await client.fetchAllPages(config.releaseApiUrl) + const releaseTagNames = currentReleases.map((release) => release.tag_name) + + info(`GET ${config.tagApiUrl}`) + const tags = await client.fetchAllPages(config.tagApiUrl) + info(`TAG_COUNT=${tags.length}`) + + for (const { tag, action } of categorizeTags(tags, releaseTagNames)) { + if (action === 'skip') { + warn('略過沒有名稱的 tag') + continue + } + + if (action === 'keep') { + info(`保留指定 release 的 tag: ${tag.name}`) + continue + } + + const url = `${config.tagApiUrl}/${tag.name}` + info(`DELETE tag ${tag.name}`) + + const code = await client.deleteResource(url) + if (code === 204) { + success(`成功刪除未指定 release 的 tag: ${tag.name}`) + } else { + fail(`刪除 tag 失敗: ${tag.name}, HTTP ${code}`) + } + } +} diff --git a/app/validate.js b/app/validate.js new file mode 100644 index 0000000..3115cf4 --- /dev/null +++ b/app/validate.js @@ -0,0 +1,36 @@ +// 參數驗證,對應原本 entrypoint.sh 的 is_empty_or_null/require_value/require_integer。 +// 驗證失敗時丟出 Error,由進入點統一捕捉後以非零狀態結束。 + +/** + * 判斷值是否視為「空」。使用嚴格相等,因此 `0`、`false`、字串 `"0"` 都不算空。 + * @param {*} value 待判斷的值 + * @returns {boolean} 當值為 `undefined`、`null`、空字串或字串 `"null"` 時回傳 true + */ +export function isEmptyOrNull(value) { + return value === undefined || value === null || value === '' || value === 'null' +} + +/** + * 要求指定欄位有值,空值時丟出 Error 以中止流程。 + * @param {string} name 欄位名稱,用於組出錯誤訊息 + * @param {*} value 待檢查的值,空值判定委派給 [[isEmptyOrNull]] + * @throws {Error} 當 value 為空時丟出 `${name} is required` + */ +export function requireValue(name, value) { + if (isEmptyOrNull(value)) { + throw new Error(`${name} is required`) + } +} + +/** + * 要求指定欄位為非負整數。會先轉成字串再以 `/^[0-9]+$/` 比對,因此可接受數字或純數字字串, + * 但拒絕負數、小數、空值與非數字內容。 + * @param {string} name 欄位名稱,用於組出錯誤訊息 + * @param {string|number} value 待檢查的值 + * @throws {Error} 當 value 不是非負整數時丟出 `${name} must be a non-negative integer` + */ +export function requireInteger(name, value) { + if (!/^[0-9]+$/.test(String(value))) { + throw new Error(`${name} must be a non-negative integer`) + } +} diff --git a/entrypoint.sh b/entrypoint.sh index dfefcad..a7ce172 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,177 +1,14 @@ -#!/usr/bin/env bash -set -Eeuo pipefail +#!/usr/bin/env sh +# ============================================================================= +# 用途: release-cleanup Action 的容器進入點 (entrypoint)。 +# 專案已由 bash 改寫為 Node.js,本腳本僅負責啟動 /app/index.js, +# 由 Node.js 程式執行實際的 release 清理邏輯。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= -separator() { - printf '\n%s\n' '==================================================' -} +# set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯。確保失敗能即時暴露。 +set -eu -section() { - separator - printf '%s\n' "$1" - printf '%s\n' '--------------------------------------------------' -} - -info() { - printf '[INFO] %s\n' "$1" -} - -success() { - printf '[OK] %s\n' "$1" -} - -warn() { - printf '[WARN] %s\n' "$1" -} - -fail() { - printf '[ERR] %s\n' "$1" >&2 -} - -is_empty_or_null() { - [ -z "${1:-}" ] || [ "${1:-}" = "null" ] -} - -require_value() { - local name="$1" - local value="$2" - - info "$name=$value" - if is_empty_or_null "$value"; then - fail "$name is required" - exit 1 - fi -} - -require_integer() { - local name="$1" - local value="$2" - - if ! [[ "$value" =~ ^[0-9]+$ ]]; then - fail "$name must be a non-negative integer" - exit 1 - fi -} - -fetch_all_pages() { - local base_url="$1" - local all_json='[]' - local page=1 - local page_url page_json - - while :; do - page_url="$base_url?page=$page" - page_json="$(curl -fsS "${auth_header[@]}" "$page_url")" - - if [ "$(jq 'length' <<<"$page_json")" -eq 0 ]; then - break - fi - - all_json="$(jq -s 'add' <<<"$all_json"$'\n'"$page_json")" - page=$((page + 1)) - done - - printf '%s' "$all_json" -} - -section "參數檢查" -require_value "GITEA_SERVER_URL" "$GITEA_SERVER_URL" -require_value "GITEA_REPOSITORY" "$GITEA_REPOSITORY" -require_value "KEEP_COUNT" "$KEEP_COUNT" -require_integer "KEEP_COUNT" "$KEEP_COUNT" - -if is_empty_or_null "${GITEA_TOKEN:-}"; then - warn "GITEA_TOKEN is empty; release API calls will be anonymous" -else - info "GITEA_TOKEN=[redacted]" -fi - -release_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases" -auth_header=() - -if ! is_empty_or_null "${GITEA_TOKEN:-}"; then - auth_header=(-H "Authorization: token $GITEA_TOKEN") -fi - -section "取得成品資訊" -info "GET $release_api_url" - -release_json="$(fetch_all_pages "$release_api_url")" -release_json="$(jq -e 'sort_by(.created_at) | reverse' <<<"$release_json")" -release_count="$(jq 'length' <<<"$release_json")" - -info "RELEASE_COUNT=$release_count" -info "KEEP_COUNT=$KEEP_COUNT" - -if [ "$release_count" -le "$KEEP_COUNT" ]; then - success "沒有需要清理的舊版本成品" -else - section "刪除舊版本成品" - - release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$release_json")" - - while IFS= read -r release_item; do - [ -z "$release_item" ] && continue - - release_id="$(jq -r '.id' <<<"$release_item")" - release_tag="$(jq -r '.tag_name' <<<"$release_item")" - release_name="$(jq -r '.name' <<<"$release_item")" - - if is_empty_or_null "$release_id"; then - warn "略過沒有 id 的成品: $release_tag ($release_name)" - continue - fi - - delete_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases/$release_id" - info "DELETE $release_tag ($release_name)" - - delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")" - - if [ "$delete_code" -eq 204 ]; then - success "成功刪除: $release_tag ($release_name)" - else - fail "刪除失敗: $release_tag ($release_name), HTTP $delete_code" - fi - done < <(jq -c '.[]' <<<"$release_to_delete") -fi - -section "刪除未指定 release 的 tag" - -# 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品 -current_release_json="$(fetch_all_pages "$release_api_url")" -release_tags_json="$(jq -c '[.[].tag_name]' <<<"$current_release_json")" - -tag_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/tags" -info "GET $tag_api_url" - -tag_json="$(fetch_all_pages "$tag_api_url")" -tag_count="$(jq 'length' <<<"$tag_json")" -info "TAG_COUNT=$tag_count" - -while IFS= read -r tag_item; do - [ -z "$tag_item" ] && continue - - tag_name="$(jq -r '.name' <<<"$tag_item")" - - if is_empty_or_null "$tag_name"; then - warn "略過沒有名稱的 tag" - continue - fi - - if [ "$(jq --arg name "$tag_name" 'any(.[]; . == $name)' <<<"$release_tags_json")" = "true" ]; then - info "保留指定 release 的 tag: $tag_name" - continue - fi - - delete_url="$tag_api_url/$tag_name" - info "DELETE tag $tag_name" - - delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")" - - if [ "$delete_code" -eq 204 ]; then - success "成功刪除未指定 release 的 tag: $tag_name" - else - fail "刪除 tag 失敗: $tag_name, HTTP $delete_code" - fi -done < <(jq -c '.[]' <<<"$tag_json") - -separator +# 進入點:實際邏輯改由 Node.js 實作,集中於 /app 目錄。 +# 以 exec 取代當前 shell process,讓 node 成為容器的 PID 1,正確接收訊號 (SIGTERM 等)。 +exec node /app/index.js -- 2.53.0 From 15ffa4ffb6e6c3ac483819a0a7b910d738db2ffe Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:35:19 +0800 Subject: [PATCH 02/43] =?UTF-8?q?test(release-cleanup):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20validate/releases/tags/gitea-client=20=E5=96=AE?= =?UTF-8?q?=E5=85=83=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 node:test 內建測試器涵蓋純函式(isEmptyOrNull/requireValue/requireInteger、 selectReleasesToDelete、categorizeTags)與 GiteaClient 分頁/刪除邏輯,共 19 項測試。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/gitea-client.test.js | 60 +++++++++++++++++++++++++++++++++++ app/test/releases.test.js | 34 ++++++++++++++++++++ app/test/tags.test.js | 40 +++++++++++++++++++++++ app/test/validate.test.js | 37 +++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 app/test/gitea-client.test.js create mode 100644 app/test/releases.test.js create mode 100644 app/test/tags.test.js create mode 100644 app/test/validate.test.js diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js new file mode 100644 index 0000000..1a70a9c --- /dev/null +++ b/app/test/gitea-client.test.js @@ -0,0 +1,60 @@ +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { GiteaClient } from '../gitea-client.js' + +const realFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = realFetch +}) + +test('建構子在有 token 時帶上 Authorization 標頭', () => { + const client = new GiteaClient({ token: 'abc' }) + assert.equal(client.headers.Authorization, 'token abc') +}) + +test('建構子在無 token 時不帶 Authorization 標頭', () => { + const client = new GiteaClient({}) + assert.equal(client.headers.Authorization, undefined) +}) + +test('fetchAllPages 逐頁讀取直到空陣列', async () => { + const pages = { + 1: [{ id: 1 }, { id: 2 }], + 2: [{ id: 3 }], + 3: [], + } + const requested = [] + globalThis.fetch = async (url) => { + const page = new URL(url).searchParams.get('page') + requested.push(page) + return { ok: true, json: async () => pages[page] } + } + + const client = new GiteaClient({}) + const items = await client.fetchAllPages('https://example.com/api') + + assert.deepEqual( + items.map((i) => i.id), + [1, 2, 3], + ) + assert.deepEqual(requested, ['1', '2', '3']) +}) + +test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => { + globalThis.fetch = async () => ({ ok: false, status: 500 }) + const client = new GiteaClient({}) + await assert.rejects( + () => client.fetchAllPages('https://example.com/api'), + /HTTP 500/, + ) +}) + +test('deleteResource 回傳 HTTP 狀態碼', async () => { + globalThis.fetch = async (url, opts) => { + assert.equal(opts.method, 'DELETE') + return { status: 204 } + } + const client = new GiteaClient({}) + assert.equal(await client.deleteResource('https://example.com/api/1'), 204) +}) diff --git a/app/test/releases.test.js b/app/test/releases.test.js new file mode 100644 index 0000000..b438545 --- /dev/null +++ b/app/test/releases.test.js @@ -0,0 +1,34 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { selectReleasesToDelete } from '../releases.js' + +const releases = [ + { id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' }, + { id: 3, tag_name: 'v3.0.0', created_at: '2024-03-01T00:00:00Z' }, + { id: 2, tag_name: 'v2.0.0', created_at: '2024-02-01T00:00:00Z' }, +] + +test('selectReleasesToDelete 保留最新的 keepCount 筆,刪除其餘', () => { + const toDelete = selectReleasesToDelete(releases, 2) + assert.equal(toDelete.length, 1) + assert.equal(toDelete[0].tag_name, 'v1.0.0') +}) + +test('selectReleasesToDelete 在數量不超過保留數時回傳空陣列', () => { + assert.deepEqual(selectReleasesToDelete(releases, 3), []) + assert.deepEqual(selectReleasesToDelete(releases, 5), []) +}) + +test('selectReleasesToDelete 在 keepCount 為 0 時刪除全部(由新到舊)', () => { + const toDelete = selectReleasesToDelete(releases, 0) + assert.deepEqual( + toDelete.map((r) => r.tag_name), + ['v3.0.0', 'v2.0.0', 'v1.0.0'], + ) +}) + +test('selectReleasesToDelete 不改動原陣列', () => { + const original = [...releases] + selectReleasesToDelete(releases, 1) + assert.deepEqual(releases, original) +}) diff --git a/app/test/tags.test.js b/app/test/tags.test.js new file mode 100644 index 0000000..d88abb2 --- /dev/null +++ b/app/test/tags.test.js @@ -0,0 +1,40 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { categorizeTags } from '../tags.js' + +const tags = [ + { name: 'v3.0.0' }, + { name: 'v2.0.0' }, + { name: 'v1.0.0' }, + { name: '' }, +] + +test('categorizeTags 保留仍被 release 指定的 tag', () => { + const result = categorizeTags(tags, ['v3.0.0', 'v2.0.0']) + const byName = Object.fromEntries( + result.map(({ tag, action }) => [tag.name, action]), + ) + assert.equal(byName['v3.0.0'], 'keep') + assert.equal(byName['v2.0.0'], 'keep') +}) + +test('categorizeTags 將未指定 release 的 tag 標記為刪除', () => { + const result = categorizeTags(tags, ['v3.0.0']) + const v1 = result.find(({ tag }) => tag.name === 'v1.0.0') + assert.equal(v1.action, 'delete') +}) + +test('categorizeTags 將無名稱的 tag 標記為略過', () => { + const result = categorizeTags(tags, []) + const empty = result.find(({ tag }) => tag.name === '') + assert.equal(empty.action, 'skip') +}) + +test('categorizeTags 在沒有任何 release 指定時刪除所有具名 tag', () => { + const result = categorizeTags(tags, []) + const deletes = result.filter(({ action }) => action === 'delete') + assert.deepEqual( + deletes.map(({ tag }) => tag.name), + ['v3.0.0', 'v2.0.0', 'v1.0.0'], + ) +}) diff --git a/app/test/validate.test.js b/app/test/validate.test.js new file mode 100644 index 0000000..d4b39d9 --- /dev/null +++ b/app/test/validate.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { isEmptyOrNull, requireValue, requireInteger } from '../validate.js' + +test('isEmptyOrNull 對空值回傳 true', () => { + assert.equal(isEmptyOrNull(''), true) + assert.equal(isEmptyOrNull(null), true) + assert.equal(isEmptyOrNull(undefined), true) + assert.equal(isEmptyOrNull('null'), true) +}) + +test('isEmptyOrNull 對有效值回傳 false', () => { + assert.equal(isEmptyOrNull('value'), false) + assert.equal(isEmptyOrNull('0'), false) + assert.equal(isEmptyOrNull(0), false) +}) + +test('requireValue 對空值丟出錯誤', () => { + assert.throws(() => requireValue('NAME', ''), /NAME is required/) + assert.throws(() => requireValue('NAME', 'null'), /NAME is required/) +}) + +test('requireValue 對有效值不丟出錯誤', () => { + assert.doesNotThrow(() => requireValue('NAME', 'value')) +}) + +test('requireInteger 接受非負整數', () => { + assert.doesNotThrow(() => requireInteger('KEEP_COUNT', '0')) + assert.doesNotThrow(() => requireInteger('KEEP_COUNT', '10')) + assert.doesNotThrow(() => requireInteger('KEEP_COUNT', 5)) +}) + +test('requireInteger 拒絕非整數或負數', () => { + assert.throws(() => requireInteger('KEEP_COUNT', '-1'), /non-negative integer/) + assert.throws(() => requireInteger('KEEP_COUNT', '1.5'), /non-negative integer/) + assert.throws(() => requireInteger('KEEP_COUNT', 'abc'), /non-negative integer/) +}) -- 2.53.0 From 4bcc6a50148cfcd70d1e0e83707b61839a2f90e3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:36:25 +0800 Subject: [PATCH 03/43] =?UTF-8?q?docs(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=20index.js=20JSDoc=20=E8=88=87=20ci/cd=20workflow=20?= =?UTF-8?q?=E8=A8=BB=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 為進入點主流程 main() 補上 JSDoc;為 ci.yaml/cd.yaml 加上用途說明與逐行註解。 皆為註解變更,不影響任何執行邏輯。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/cd.yaml | 15 +++++++++++++++ .gitea/workflows/ci.yaml | 21 +++++++++++++++++++++ app/index.js | 8 ++++++++ 3 files changed, 44 insertions(+) diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 30b1952..7d58a9c 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1,12 +1,27 @@ +# ============================================================================= +# 用途: release-cleanup 專案的 CD workflow。 +# 在程式碼推送至 master 分支時,自動釋出並標註 (tag) 成品版本。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= + +# workflow 名稱,顯示於 Gitea Actions 介面。 name: CD +# 觸發條件設定。 on: + # 於 push 事件觸發。 push: + # 僅在推送至 master 分支時觸發。 branches: - master jobs: + # job 識別碼:釋出並標註版本。 release-tag-version: + # job 顯示名稱。 name: Release Tag Version + # 指定執行於 ubuntu runner。 runs-on: ubuntu steps: + # 呼叫共用的 release-tag-version composite action,自動產生版本標籤並釋出。 - name: 釋出並標註成品版本 + # 版本以變數 ACTION_RELEASE_TAG_VERSION 控制,便於統一升級。 uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b9d4eed..12cd0bc 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,19 +1,40 @@ +# ============================================================================= +# 用途: release-cleanup 專案的 CI workflow。 +# 在 Pull Request 開啟或更新時,觸發 OpenCode AI 程式碼審查。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= + +# workflow 名稱,顯示於 Gitea Actions 介面。 name: CI +# 觸發條件設定。 on: + # 於 Pull Request 事件觸發。 pull_request: + # 忽略目標分支為 master 的 PR (master 走 CD 流程,不在此審查)。 branches-ignore: - master + # 僅在 PR 開啟 (opened) 或有新 commit 推送 (synchronize) 時觸發。 types: [opened, synchronize] jobs: + # job 識別碼:AI 程式碼審查。 ai-code-review: + # job 顯示名稱。 name: AI Code Review + # 指定執行於 ubuntu runner。 runs-on: ubuntu + # 此 job 所需的權限 (供 AI 寫回審查結果)。 permissions: + # 可寫入儲存庫內容。 contents: write + # 可在 PR 上留言/變更狀態。 pull-requests: write + # 可建立/更新 issue。 issues: write steps: + # 呼叫共用的 OpenCode 程式碼審查 composite action。 - name: AI 程式碼審查 by OpenCode + # 版本以變數 ACTION_OPENCODE_CODE_REVIEW_VERSION 控制,便於統一升級。 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 }} diff --git a/app/index.js b/app/index.js index fceefc1..4cb2d44 100644 --- a/app/index.js +++ b/app/index.js @@ -6,6 +6,14 @@ import { cleanupReleases } from './releases.js' import { cleanupOrphanTags } from './tags.js' import { separator, fail } from './logger.js' +/** + * Action 主流程:讀取並驗證環境設定、建立帶認證的 Gitea 客戶端, + * 先清理超出保留數量的舊 release,再清理未被任何 release 指定的孤立 tag,最後輸出結尾分隔線。 + * + * 任一步驟拋出的例外不在此攔截,改由模組層級的 `main().catch(...)` 統一處理並以非零狀態結束。 + * + * @returns {Promise} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。 + */ async function main() { const config = loadConfig() const client = new GiteaClient({ token: config.token }) -- 2.53.0 From 7082fd731171d7870ec79eb62143578e9cbfaf41 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 02:38:03 +0000 Subject: [PATCH 04/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 146 +++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 .gitea/ai-review/findings.json diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json new file mode 100644 index 0000000..594dba5 --- /dev/null +++ b/.gitea/ai-review/findings.json @@ -0,0 +1,146 @@ +[ + { + "level": "critical", + "role": "Assassin", + "location": "app/config.js:21", + "problem": "GITEA_SERVER_URL 缺乏嚴格的格式驗證與協定強制。攻擊者若控制此環境變數,可將其設定為任意惡意伺服器(例如 http://attacker.com),引發 SSRF 攻擊或在沒有 HTTPS 的情況下,導致傳輸過程中 GITEA_TOKEN 被竊取。", + "suggestion": "應使用 Node.js 的 URL 類別驗證 GITEA_SERVER_URL 格式是否合法,並強制使用 HTTPS 協定。若可能,建議實作 allowed host 的白名單機制。", + "is_new": true + }, + { + "level": "critical", + "role": "Assassin", + "location": "app/config.js:39", + "problem": "GITEA_REPOSITORY 變數直接被拼接進 API URL 中,卻未進行任何消毒。攻擊者可利用 Path Traversal(例如設定為 ../../other-repo)繞過權限,導致 API 呼叫目標被竄改,進而執行未授權的動作。", + "suggestion": "必須對 GITEA_REPOSITORY 進行格式驗證,僅允許合法的 Repository 名稱字元(例如英數字、`-`、`_`、`/`),且必須拒絕包含 `..` 或其他路徑穿越字元的輸入。", + "is_new": true + }, + { + "level": "critical", + "role": "Mage", + "location": "app/gitea-client.js:31", + "problem": "在 `fetchAllPages` 的 `while` 迴圈中直接使用 `await fetch` 而沒有設定逾時(timeout)。若 Gitea API 伺服器因負載過重導致回應緩慢或掛起,該容器進程將會永久卡死,無法釋放資源或失敗重試。", + "suggestion": "建議使用 `AbortController` 設定合理的逾時時間(例如:`{ signal: AbortSignal.timeout(30000) }`),確保網路呼叫在預期時間內未完成時能主動拋出例外。", + "is_new": true + }, + { + "level": "critical", + "role": "Mage", + "location": "app/index.js:26", + "problem": "當 `main().catch(...)` 捕捉到錯誤並執行 `process.exit(1)` 時,僅輸出 `error.message`。若錯誤是由 `fetch` 網路層拋出(例如 DNS 解析失敗或 connection refused),Node.js 的 `Error` 物件可能不包含足夠的上下文訊息,導致使用者難以區分是 API 回傳錯誤還是程式碼執行期錯誤。", + "suggestion": "建議在 `catch` 區塊中,若錯誤是 `Error` 物件,輸出 `error.stack` 或至少記錄錯誤類型,並區分不同層級的例外(例如:驗證錯誤 vs. API 請求錯誤)。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/releases.js:21", + "problem": "cleanupReleases 是執行刪除舊成品的核心函式,但目前缺乏針對 API 呼叫失敗(如 GET 失敗、DELETE 失敗)的測試,無法驗證錯誤處理邏輯是否如預期運作。", + "suggestion": "使用測試框架搭配 mock 伺服器回應,補齊 cleanupReleases 的測試案例,特別是驗證當 deleteResource 回傳非 204 狀態碼時,系統是否正確記錄錯誤並繼續或中斷。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/tags.js:35", + "problem": "cleanupOrphanTags 涉及多次 API 交互,目前缺乏測試驗證邏輯,無法確保在 API 請求失敗或標籤分類錯誤時系統的行為一致性。", + "suggestion": "補齊 cleanupOrphanTags 的測試案例,需模擬 fetchAllPages 回傳內容,並驗證對於不同 action(keep/delete/skip)的處理流程是否正確。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/releases.js:14", + "problem": "使用 `new Date(release.created_at)` 進行排序時,若 `created_at` 格式非預期或為空,會導致 `NaN` 並造成排序異常,可能無法正確刪除舊版本。", + "suggestion": "建議在排序邏輯中增加對 `created_at` 的有效性檢查,若無效則給予預設值(例如:`new Date(0)`),確保排序結果可預期。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/gitea-client.js:25", + "problem": "HTTP 請求 (fetch) 未設定逾時時間,若 API 伺服器回應緩慢或網路阻塞,可能導致此 Actions 執行過程長時間卡死,造成除錯與排程上的困難。", + "suggestion": "建議使用 `AbortController` 為所有 `fetch` 請求設定合理的逾時限制(例如 30 秒),並正確處理 `AbortError`。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/gitea-client.js:28", + "problem": "在 `fetchAllPages` 中未檢查 API 回傳的內容是否符合預期格式(除了 Array 檢查)。若 API 回傳非 JSON 格式的內容(例如 HTML 錯誤頁面),`res.json()` 會拋出 SyntaxError,且未被目前邏輯中的 `try-catch` 明確攔截處理,會導致程式在 catch 區塊中直接結束並輸出錯誤訊息,缺乏更細緻的除錯資訊。", + "suggestion": "在解析 JSON 前,應先判斷 `res.headers.get('content-type')` 是否包含 `application/json`,若非 JSON,應將 `res.text()` 的內容一併在錯誤訊息中輸出,方便排查。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:46", + "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 `fail` 並繼續執行,可能導致後續刪除邏輯的不一致。", + "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/tags.js:56", + "problem": "在 `cleanupOrphanTags` 中,雖然先重新 fetch 了 release 清單,但 `cleanupReleases` 和 `cleanupOrphanTags` 是非同步執行,且中間無確保一致性的機制。若在 `cleanupReleases` 刪除完成後到 `cleanupOrphanTags` 執行期間,Gitea 上有新的 release 被建立,則 `releaseTagNames` 的快照將會過時,導致正在使用的 tag 被錯誤刪除。", + "suggestion": "考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/config.js:11", + "problem": "loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。", + "suggestion": "補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/index.js:17", + "problem": "main 函式作為整個流程的入口,缺乏端對端(E2E)或整合測試,無法確保各模組整合後在失敗情境下(如 process.exit(1) 的觸發)是否能正確運作。", + "suggestion": "針對 main 函式建立整合測試,至少驗證在拋出例外時,logger.fail 是否有被呼叫且程序是否以錯誤代碼結束。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/releases.js:43", + "problem": "在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。", + "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/tags.js:46", + "problem": "同樣在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。", + "suggestion": "同樣改用 `Promise.all` 將刪除請求並行化,加快清理速度。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "Dockerfile:13", + "problem": "注释中的冒号后面缺少空格,阅读节奏感稍显拥挤。", + "suggestion": "请在冒号后面加上一个空格,让注释读起来更舒畅:`# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。`", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "Dockerfile:16", + "problem": "注释中括号内的说明与前文缺少空格区隔,排版不够优雅。", + "suggestion": "在括号前面增加一个空格:`# 複製 Node.js 應用程式 (不含測試)`", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "app/config.js:31", + "problem": "日志信息的键值对缺少空格,排版不够整齐统一。", + "suggestion": "建议加上空格以提升易读性:`info('GITEA_TOKEN = [redacted]')`", + "is_new": true + } +] -- 2.53.0 From 7e6f9ef3f66f223b22ca4e1018b9d3d31aed7187 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:42:53 +0800 Subject: [PATCH 05/43] =?UTF-8?q?docs(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E9=BD=8A=20config/gitea-client/releases/tags=20=E7=9A=84=20JSD?= =?UTF-8?q?oc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 為 loadConfig、GiteaClient(含 fetchAllPages/deleteResource)、 selectReleasesToDelete/cleanupReleases、categorizeTags/cleanupOrphanTags 補上完整 JSDoc(參數、回傳、例外、行為說明)。僅註解變更。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 14 +++++++++++--- app/gitea-client.js | 23 +++++++++++++++++------ app/releases.js | 22 ++++++++++++++-------- app/tags.js | 26 +++++++++++++++++--------- 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/app/config.js b/app/config.js index eaf8de4..f0278ce 100644 --- a/app/config.js +++ b/app/config.js @@ -5,9 +5,17 @@ import { isEmptyOrNull, requireValue, requireInteger } from './validate.js' import { section, info, warn } from './logger.js' /** - * 從環境變數載入設定並完成驗證。 - * @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 process.env - * @returns 包含 API 位址、token、保留數量等資訊的設定物件 + * 從環境變數載入設定並完成驗證,作為整個清理流程的設定來源。 + * + * 讀取 `GITEA_SERVER_URL`、`GITEA_REPOSITORY`、`KEEP_COUNT`、`GITEA_TOKEN`, + * 其中前三者為必填(`KEEP_COUNT` 另須為非負整數);`GITEA_TOKEN` 為選填, + * 為空時改以匿名方式呼叫 API。驗證過程會將參數印出(token 以 `[redacted]` 遮蔽)。 + * + * @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 `process.env`,可於測試時注入假值 + * @returns {{serverUrl: string, repository: string, token: string|null, keepCount: number, releaseApiUrl: string, tagApiUrl: string}} + * 設定物件:`token` 在環境變數為空時為 `null`;`keepCount` 為數值; + * `releaseApiUrl`/`tagApiUrl` 為依 server 與 repository 組出的 API 位址。 + * @throws {Error} 任一必填項缺漏,或 `KEEP_COUNT` 非非負整數時拋出(由 [[validate]] 的檢查函式丟出) */ export function loadConfig(env = process.env) { section('參數檢查') diff --git a/app/gitea-client.js b/app/gitea-client.js index bb810f3..d5a7bf6 100644 --- a/app/gitea-client.js +++ b/app/gitea-client.js @@ -1,9 +1,15 @@ // 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 // 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 +/** + * 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。 + * 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。 + */ export class GiteaClient { /** - * @param {{ token?: string | null }} options 認證設定;有 token 時帶上 Authorization 標頭 + * 建立客戶端;有 token 時於後續所有請求帶上 `Authorization: token ` 標頭, + * 無 token(或為 `null`)時以匿名方式呼叫。 + * @param {{ token?: string | null }} [options] 認證設定 */ constructor({ token = null } = {}) { this.headers = {} @@ -13,9 +19,14 @@ export class GiteaClient { } /** - * 逐頁讀取分頁式清單 API,直到回傳空陣列為止,合併成單一陣列。 - * @param {string} baseUrl 不含 query string 的 API 位址 - * @returns {Promise} 所有頁面合併後的項目 + * 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列(或非陣列)為止, + * 將所有頁面項目合併成單一陣列回傳。 + * + * 注意:終止條件依賴「空陣列代表最後一頁」的假設;若 API 不以空陣列結尾則迴圈不會自然停止。 + * + * @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`) + * @returns {Promise} 所有頁面合併後的項目陣列;無資料時為空陣列 + * @throws {Error} 任一頁回應 HTTP 非 2xx(`!res.ok`)時拋出,訊息含 URL 與狀態碼 */ async fetchAllPages(baseUrl) { const all = [] @@ -42,9 +53,9 @@ export class GiteaClient { } /** - * 對指定資源發出 DELETE 請求。 + * 對指定資源發出 DELETE 請求,僅回傳 HTTP 狀態碼供呼叫端判斷成敗(不檢查 ok、不解析回應內容)。 * @param {string} url 目標資源位址 - * @returns {Promise} HTTP 狀態碼 + * @returns {Promise} 回應的 HTTP 狀態碼(成功刪除通常為 204) */ async deleteResource(url) { const res = await fetch(url, { method: 'DELETE', headers: this.headers }) diff --git a/app/releases.js b/app/releases.js index 418ecbd..9ec6881 100644 --- a/app/releases.js +++ b/app/releases.js @@ -5,11 +5,11 @@ import { section, info, success, fail, warn } from './logger.js' import { isEmptyOrNull } from './validate.js' /** - * 依建立時間由新到舊排序,保留最新的 keepCount 筆,回傳其餘待刪除的成品。 - * 純函式,方便單元測試。 - * @param {any[]} releases 成品清單 - * @param {number} keepCount 要保留的筆數 - * @returns {any[]} 需要刪除的成品(較舊者) + * 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。 + * 以展開運算子複製陣列後再排序,不會改動傳入的原陣列。為純函式,方便單元測試。 + * @param {any[]} releases 成品清單,每個元素預期含有 `created_at` 欄位 + * @param {number} keepCount 要保留的最新筆數;為 0 時代表全部刪除 + * @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序 */ export function selectReleasesToDelete(releases, keepCount) { const sorted = [...releases].sort( @@ -19,9 +19,15 @@ export function selectReleasesToDelete(releases, keepCount) { } /** - * 讀取成品清單,刪除超出保留數量的舊版本成品。 - * @param {import('./gitea-client.js').GiteaClient} client - * @param {ReturnType} config + * 讀取全部成品清單,刪除超出保留數量的舊版本成品。 + * + * 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→ + * 否則以 [[selectReleasesToDelete]] 取出待刪除清單,逐筆刪除(略過沒有 `id` 的項目), + * 依回應狀態碼 204 判定成功與否並輸出結果。 + * + * @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端 + * @param {ReturnType} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount` + * @returns {Promise} */ export async function cleanupReleases(client, config) { section('取得成品資訊') diff --git a/app/tags.js b/app/tags.js index 230de91..c991da3 100644 --- a/app/tags.js +++ b/app/tags.js @@ -5,12 +5,14 @@ import { section, info, success, fail, warn } from './logger.js' import { isEmptyOrNull } from './validate.js' /** - * 將 tag 分類為保留、刪除或略過(無名稱)。 - * 仍被任一 release 指定的 tag 予以保留,其餘視為孤立 tag 待刪除。 - * 純函式,方便單元測試。 - * @param {any[]} tags tag 清單 - * @param {Iterable} releaseTagNames 仍被 release 指定的 tag 名稱 - * @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} + * 將每個 tag 分類為保留、刪除或略過三類,判斷優先序為:無名稱 → skip、仍被指定 → keep、其餘 → delete。 + * + * 先把 `releaseTagNames` 收進 `Set` 以利 O(1) 比對;名稱經 [[isEmptyOrNull]] 判定為空者標為 `skip`, + * 仍被任一 release 指定者標為 `keep`,其餘視為孤立 tag 標為 `delete`。為純函式,無副作用,方便單元測試。 + * + * @param {any[]} tags tag 清單,每個元素預期含有 `name` 欄位 + * @param {Iterable} releaseTagNames 仍被 release 指定的 tag 名稱集合 + * @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} 與輸入等長且保持原順序的分類結果 */ export function categorizeTags(tags, releaseTagNames) { const keep = new Set(releaseTagNames) @@ -27,9 +29,15 @@ export function categorizeTags(tags, releaseTagNames) { } /** - * 重新讀取成品清單以取得仍被指定的 tag,再刪除未指定 release 的孤立 tag。 - * @param {import('./gitea-client.js').GiteaClient} client - * @param {ReturnType} config + * 重新讀取成品清單以取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。 + * + * 流程:重新抓取全部 release(反映刪除舊版本後的最新狀態)並取出其 `tag_name` 集合 → + * 抓取全部 tag → 以 [[categorizeTags]] 分類,對 `skip` 警告略過、`keep` 保留, + * 對 `delete` 發出刪除並依狀態碼 204 判定成功與否。 + * + * @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端 + * @param {ReturnType} config 設定物件,使用其 `releaseApiUrl` 與 `tagApiUrl` + * @returns {Promise} */ export async function cleanupOrphanTags(client, config) { section('刪除未指定 release 的 tag') -- 2.53.0 From 1f27a46f7b2686af884206f17cf4f3d09b841b7c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:42:53 +0800 Subject: [PATCH 06/43] =?UTF-8?q?docs(README):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=B0=88=E6=A1=88=20README=20=E8=88=87=E5=8A=9F=E8=83=BD/?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=AF=84=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增專案列表、公開功能列表(連結至 develop 上的原始碼行)與使用範例; 連結指向 develop 分支以確保合併後仍有效。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 289 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..00a76ed --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# release-cleanup + +release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。 + +> 更新時間:2026/06/26 10:40:15 + +## 專案列表 + +### 專案描述 + +| 專案名稱 | 專案描述 | +| --- | --- | +| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | Gitea Docker Action 的 Node.js 實作。提供主控台輸出、參數驗證、設定載入、Gitea API 客戶端,以及清理舊版本 release 與孤立 tag 的功能。 | + +### 參考專案 + +| 專案名稱 | 參考專案列表 | +| --- | --- | +| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 無 | + +### 相依套件(npm) + +| 專案名稱 | 套件列表 | +| --- | --- | +| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 無(僅使用 Node.js 內建模組與 `node:test`) | + +## 功能列表 + +### release-cleanup + +| 功能名稱 | 功能描述 | +| --- | --- | +| [separator](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L9) | [輸出一條等號分隔線至 stdout。](#separator) | +| [section](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L17) | [輸出區段標題(分隔線 + 標題 + 虛線)。](#section) | +| [info](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L27) | [以 `[INFO]` 前綴輸出一般資訊。](#info) | +| [success](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L35) | [以 `[OK]` 前綴輸出成功訊息。](#success) | +| [warn](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L43) | [以 `[WARN]` 前綴輸出警告訊息。](#warn) | +| [fail](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L51) | [以 `[ERR]` 前綴輸出錯誤訊息至 stderr。](#fail) | +| [isEmptyOrNull](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L9) | [判斷值是否為空(空字串、null、undefined 或字串 "null")。](#isemptyornull) | +| [requireValue](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L19) | [要求欄位有值,空值時丟出 Error。](#requirevalue) | +| [requireInteger](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L32) | [要求欄位為非負整數,否則丟出 Error。](#requireinteger) | +| [loadConfig](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/config.js#L20) | [從環境變數載入並驗證設定,回傳設定物件。](#loadconfig) | +| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L8) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | +| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L31) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | +| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L60) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | +| [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L14) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) | +| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L32) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | +| [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L17) | [將 tag 分類為保留、刪除或略過。](#categorizetags) | +| [cleanupOrphanTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L42) | [刪除未被任何 release 指定的孤立 tag。](#cleanuporphantags) | + +## 使用範例 + + +### separator + +輸出一條前後換行的等號分隔線至 stdout,用於視覺上區隔不同階段的輸出。 + +```js +import { separator } from './logger.js' + +separator() +// 輸出: +// +// ================================================== +``` + + +### section + +先印分隔線,再印標題與一條虛線,用於標示流程進入新階段。 + +```js +import { section } from './logger.js' + +section('參數檢查') +// ================================================== +// 參數檢查 +// -------------------------------------------------- +``` + + +### info + +以 `[INFO]` 前綴輸出一般資訊訊息至 stdout。 + +```js +import { info } from './logger.js' + +info('GET https://gitea.example.com/api/v1/repos/owner/repo/releases') +// [INFO] GET https://gitea.example.com/api/v1/repos/owner/repo/releases +``` + + +### success + +以 `[OK]` 前綴輸出成功訊息(前綴補空白以對齊其他標籤)。 + +```js +import { success } from './logger.js' + +success('成功刪除: v1.0.0 (Release 1.0.0)') +// [OK] 成功刪除: v1.0.0 (Release 1.0.0) +``` + + +### warn + +以 `[WARN]` 前綴輸出警告訊息;為與一般輸出同流,仍寫入 stdout。 + +```js +import { warn } from './logger.js' + +warn('GITEA_TOKEN is empty; release API calls will be anonymous') +// [WARN] GITEA_TOKEN is empty; release API calls will be anonymous +``` + + +### fail + +以 `[ERR]` 前綴輸出錯誤訊息至 stderr(唯一寫入 stderr 的輸出函式)。 + +```js +import { fail } from './logger.js' + +fail('刪除失敗: v0.9.0 (舊版), HTTP 500') +// (stderr) [ERR] 刪除失敗: v0.9.0 (舊版), HTTP 500 +``` + + +### isEmptyOrNull + +判斷值是否視為「空」。使用嚴格相等,因此 `0`、`false`、字串 `"0"` 都不算空;`undefined`、`null`、空字串與字串 `"null"` 才回傳 `true`。 + +```js +import { isEmptyOrNull } from './validate.js' + +isEmptyOrNull('') // true +isEmptyOrNull('null') // true +isEmptyOrNull(0) // false +isEmptyOrNull('v1.0') // false +``` + + +### requireValue + +要求指定欄位有值,空值時丟出 Error 以中止流程。空值判定委派給 `isEmptyOrNull`。 + +```js +import { requireValue } from './validate.js' + +requireValue('GITEA_SERVER_URL', 'https://gitea.example.com') // 通過 +requireValue('GITEA_SERVER_URL', '') // 丟出 Error: GITEA_SERVER_URL is required +``` + + +### requireInteger + +要求欄位為非負整數(可為數字或純數字字串),拒絕負數、小數、空值與非數字內容。 + +```js +import { requireInteger } from './validate.js' + +requireInteger('KEEP_COUNT', '2') // 通過 +requireInteger('KEEP_COUNT', 0) // 通過 +requireInteger('KEEP_COUNT', '-1') // 丟出 Error: KEEP_COUNT must be a non-negative integer +``` + + +### loadConfig + +從環境變數載入並驗證設定,回傳供清理流程使用的設定物件;必填項缺漏或 `KEEP_COUNT` 非整數時丟出 Error。 + +```js +import { loadConfig } from './config.js' + +// 由 process.env 讀取(GITEA_SERVER_URL / GITEA_REPOSITORY / KEEP_COUNT / GITEA_TOKEN) +const config = loadConfig() + +// 測試時可注入假環境變數 +const cfg = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + KEEP_COUNT: '2', +}) +// cfg.releaseApiUrl === 'https://gitea.example.com/api/v1/repos/owner/repo/releases' +``` + + +### GiteaClient + +建立與 Gitea API 溝通的客戶端;傳入 token 時於後續請求帶上 `Authorization` 標頭,否則以匿名方式呼叫。 + +```js +import { GiteaClient } from './gitea-client.js' + +const client = new GiteaClient({ token: process.env.GITEA_TOKEN }) +const anonymous = new GiteaClient() // 無 token,匿名呼叫 +``` + + +### GiteaClient.fetchAllPages + +逐頁讀取分頁式清單 API(每頁以 `?page=N` 遞增),直到回傳空陣列為止,合併成單一陣列;任一頁 HTTP 非 2xx 時丟出 Error。 + +```js +const client = new GiteaClient({ token: process.env.GITEA_TOKEN }) +const releases = await client.fetchAllPages( + 'https://gitea.example.com/api/v1/repos/owner/repo/releases', +) +console.log(`共取得 ${releases.length} 筆 release`) +``` + + +### GiteaClient.deleteResource + +對指定資源發出 DELETE 請求,回傳 HTTP 狀態碼供呼叫端判斷成敗(成功刪除通常為 204)。 + +```js +const code = await client.deleteResource( + 'https://gitea.example.com/api/v1/repos/owner/repo/releases/123', +) +if (code === 204) { + console.log('刪除成功') +} +``` + + +### selectReleasesToDelete + +依 `created_at` 由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的 release;為純函式,不改動原陣列。 + +```js +import { selectReleasesToDelete } from './releases.js' + +const releases = [ + { id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' }, + { id: 2, tag_name: 'v2.0.0', created_at: '2024-02-01T00:00:00Z' }, + { id: 3, tag_name: 'v3.0.0', created_at: '2024-03-01T00:00:00Z' }, +] +selectReleasesToDelete(releases, 2) +// [{ id: 1, tag_name: 'v1.0.0', ... }] // 僅保留最新兩筆,回傳最舊的一筆 +``` + + +### cleanupReleases + +讀取全部 release,刪除超出保留數量的舊版本;總數不超過 `keepCount` 時不做任何刪除。 + +```js +import { loadConfig } from './config.js' +import { GiteaClient } from './gitea-client.js' +import { cleanupReleases } from './releases.js' + +const config = loadConfig() +const client = new GiteaClient({ token: config.token }) +await cleanupReleases(client, config) +``` + + +### categorizeTags + +將每個 tag 分類為 `keep`(仍被 release 指定)、`delete`(孤立 tag)或 `skip`(無名稱);為純函式,方便測試。 + +```js +import { categorizeTags } from './tags.js' + +const tags = [{ name: 'v2.0.0' }, { name: 'v1.0.0' }, { name: '' }] +categorizeTags(tags, ['v2.0.0']) +// [ +// { tag: { name: 'v2.0.0' }, action: 'keep' }, +// { tag: { name: 'v1.0.0' }, action: 'delete' }, +// { tag: { name: '' }, action: 'skip' }, +// ] +``` + + +### cleanupOrphanTags + +重新讀取 release 清單取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。 + +```js +import { loadConfig } from './config.js' +import { GiteaClient } from './gitea-client.js' +import { cleanupOrphanTags } from './tags.js' + +const config = loadConfig() +const client = new GiteaClient({ token: config.token }) +await cleanupOrphanTags(client, config) +``` -- 2.53.0 From 154ab032bffe5dab2ab52b6d1c6e19b1bd9e089d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:55:45 +0800 Subject: [PATCH 07/43] =?UTF-8?q?fix(release-cleanup):=20=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E8=BC=B8=E5=85=A5=E9=A9=97=E8=AD=89=E3=80=81=E8=AB=8B?= =?UTF-8?q?=E6=B1=82=E9=80=BE=E6=99=82=E8=88=87=E9=8C=AF=E8=AA=A4=E8=99=95?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解決 AI review 的安全性與健壯性問題: - 驗證 GITEA_SERVER_URL 為合法 http/https URL(降低 SSRF 風險) - 驗證 GITEA_REPOSITORY 為 owner/repo 形式並拒絕路徑穿越 - 為所有 fetch 請求加上 30 秒逾時(AbortSignal.timeout),避免卡死 - fetchAllPages 解析前檢查 content-type,非 JSON 時拋出明確錯誤 - selectReleasesToDelete 對無效 created_at 防呆,避免 NaN 排序 - index.js 匯出 main 並加上直接執行守衛,錯誤輸出含類型與堆疊 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 10 +++++++++- app/gitea-client.js | 28 +++++++++++++++++++++++++--- app/index.js | 29 ++++++++++++++++++++++++----- app/releases.js | 9 ++++++--- app/validate.js | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/app/config.js b/app/config.js index f0278ce..f2895cc 100644 --- a/app/config.js +++ b/app/config.js @@ -1,7 +1,13 @@ // 讀取並驗證環境變數,組出後續流程所需的設定物件。 // 對應原本 entrypoint.sh 的「參數檢查」區段。 -import { isEmptyOrNull, requireValue, requireInteger } from './validate.js' +import { + isEmptyOrNull, + requireValue, + requireInteger, + requireUrl, + requireRepository, +} from './validate.js' import { section, info, warn } from './logger.js' /** @@ -27,9 +33,11 @@ export function loadConfig(env = process.env) { info(`GITEA_SERVER_URL=${serverUrl}`) requireValue('GITEA_SERVER_URL', serverUrl) + requireUrl('GITEA_SERVER_URL', serverUrl) info(`GITEA_REPOSITORY=${repository}`) requireValue('GITEA_REPOSITORY', repository) + requireRepository('GITEA_REPOSITORY', repository) info(`KEEP_COUNT=${keepCountRaw}`) requireValue('KEEP_COUNT', keepCountRaw) diff --git a/app/gitea-client.js b/app/gitea-client.js index d5a7bf6..6f6ce8e 100644 --- a/app/gitea-client.js +++ b/app/gitea-client.js @@ -1,6 +1,9 @@ // 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 // 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 +// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。 +const REQUEST_TIMEOUT_MS = 30000 + /** * 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。 * 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。 @@ -34,10 +37,25 @@ export class GiteaClient { while (true) { const url = `${baseUrl}?page=${page}` - const res = await fetch(url, { headers: this.headers }) + const res = await fetch(url, { + headers: this.headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) if (!res.ok) { - throw new Error(`GET ${url} failed: HTTP ${res.status}`) + const body = await res.text().catch(() => '') + throw new Error( + `GET ${url} failed: HTTP ${res.status}${body ? ` - ${body.slice(0, 200)}` : ''}`, + ) + } + + // 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。 + const contentType = res.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { + const body = await res.text().catch(() => '') + throw new Error( + `GET ${url} returned non-JSON content-type "${contentType}": ${body.slice(0, 200)}`, + ) } const items = await res.json() @@ -58,7 +76,11 @@ export class GiteaClient { * @returns {Promise} 回應的 HTTP 狀態碼(成功刪除通常為 204) */ async deleteResource(url) { - const res = await fetch(url, { method: 'DELETE', headers: this.headers }) + const res = await fetch(url, { + method: 'DELETE', + headers: this.headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) return res.status } } diff --git a/app/index.js b/app/index.js index 4cb2d44..ec5c3fb 100644 --- a/app/index.js +++ b/app/index.js @@ -14,7 +14,7 @@ import { separator, fail } from './logger.js' * * @returns {Promise} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。 */ -async function main() { +export async function main() { const config = loadConfig() const client = new GiteaClient({ token: config.token }) @@ -24,7 +24,26 @@ async function main() { separator() } -main().catch((error) => { - fail(error.message) - process.exit(1) -}) +/** + * 將錯誤輸出至 stderr,並盡量保留可供除錯的上下文(錯誤類型與堆疊), + * 以便區分設定驗證錯誤與網路/API 請求錯誤。 + * @param {unknown} error 捕捉到的錯誤 + */ +function reportFatal(error) { + if (error instanceof Error) { + fail(`${error.name}: ${error.message}`) + if (error.stack) { + process.stderr.write(`${error.stack}\n`) + } + } else { + fail(String(error)) + } +} + +// 僅在直接以 `node index.js` 執行時啟動主流程;被測試 import 時不自動執行,方便撰寫整合測試。 +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + reportFatal(error) + process.exit(1) + }) +} diff --git a/app/releases.js b/app/releases.js index 9ec6881..4bd40f3 100644 --- a/app/releases.js +++ b/app/releases.js @@ -12,9 +12,12 @@ import { isEmptyOrNull } from './validate.js' * @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序 */ export function selectReleasesToDelete(releases, keepCount) { - const sorted = [...releases].sort( - (a, b) => new Date(b.created_at) - new Date(a.created_at), - ) + // 解析 created_at;格式無效或缺漏時視為最舊(0),避免 NaN 造成排序結果不可預期。 + const createdTime = (release) => { + const time = Date.parse(release?.created_at) + return Number.isNaN(time) ? 0 : time + } + const sorted = [...releases].sort((a, b) => createdTime(b) - createdTime(a)) return sorted.slice(keepCount) } diff --git a/app/validate.js b/app/validate.js index 3115cf4..7c2ee95 100644 --- a/app/validate.js +++ b/app/validate.js @@ -34,3 +34,35 @@ export function requireInteger(name, value) { throw new Error(`${name} must be a non-negative integer`) } } + +/** + * 要求值為合法的 http/https URL,藉此避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。 + * @param {string} name 欄位名稱,用於組出錯誤訊息 + * @param {string} value 待檢查的 URL 字串 + * @throws {Error} 當 value 無法解析為 URL 時丟出 `${name} must be a valid URL`; + * 協定非 http/https 時丟出 `${name} must use http or https protocol` + */ +export function requireUrl(name, value) { + let url + try { + url = new URL(value) + } catch { + throw new Error(`${name} must be a valid URL`) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${name} must use http or https protocol`) + } +} + +/** + * 要求值為合法的 `owner/repo` 形式:僅允許英數字與 `. _ -`、恰好一個 `/`,且不可含路徑穿越片段 `..`。 + * 用於防止 GITEA_REPOSITORY 被竄改造成 API 目標被導向其他儲存庫。 + * @param {string} name 欄位名稱,用於組出錯誤訊息 + * @param {string} value 待檢查的 repository 字串 + * @throws {Error} 格式不符或含 `..` 時丟出 `${name} must be in the form owner/repo without path traversal` + */ +export function requireRepository(name, value) { + if (String(value).includes('..') || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)) { + throw new Error(`${name} must be in the form owner/repo without path traversal`) + } +} -- 2.53.0 From 7c225d412274295f6bed7f2e34d3295ef646c4be Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:55:45 +0800 Subject: [PATCH 08/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E9=BD=8A=20cleanup/config/main=20=E8=88=87=E6=96=B0=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E5=87=BD=E5=BC=8F=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 cleanupReleases、cleanupOrphanTags 的失敗路徑與分類測試、 loadConfig 環境變數驗證測試、main 整合測試,以及 requireUrl/ requireRepository 與 content-type 檢查測試。測試共 41 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/config.test.js | 58 ++++++++++++++++++++++++++++ app/test/gitea-client.test.js | 46 ++++++++++++++++++++-- app/test/main.test.js | 43 +++++++++++++++++++++ app/test/releases.test.js | 73 ++++++++++++++++++++++++++++++++++- app/test/tags.test.js | 50 +++++++++++++++++++++++- app/test/validate.test.js | 31 ++++++++++++++- 6 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 app/test/config.test.js create mode 100644 app/test/main.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js new file mode 100644 index 0000000..cd1975c --- /dev/null +++ b/app/test/config.test.js @@ -0,0 +1,58 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { loadConfig } from '../config.js' + +const base = { + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + KEEP_COUNT: '2', +} + +test('loadConfig 在缺少 GITEA_SERVER_URL 時丟出', () => { + assert.throws( + () => loadConfig({ GITEA_REPOSITORY: 'owner/repo', KEEP_COUNT: '2' }), + /GITEA_SERVER_URL is required/, + ) +}) + +test('loadConfig 在 KEEP_COUNT 非整數時丟出', () => { + assert.throws( + () => loadConfig({ ...base, KEEP_COUNT: 'abc' }), + /non-negative integer/, + ) +}) + +test('loadConfig 在 GITEA_SERVER_URL 非合法 URL 時丟出', () => { + assert.throws( + () => loadConfig({ ...base, GITEA_SERVER_URL: 'not a url' }), + /must be a valid URL/, + ) +}) + +test('loadConfig 在 GITEA_REPOSITORY 含路徑穿越時丟出', () => { + assert.throws( + () => loadConfig({ ...base, GITEA_REPOSITORY: '../evil' }), + /owner\/repo without path traversal/, + ) +}) + +test('loadConfig 在有效輸入時回傳完整設定物件', () => { + const cfg = loadConfig(base) + assert.equal(cfg.serverUrl, 'https://gitea.example.com') + assert.equal(cfg.repository, 'owner/repo') + assert.equal(cfg.token, null) + assert.equal(cfg.keepCount, 2) + assert.equal( + cfg.releaseApiUrl, + 'https://gitea.example.com/api/v1/repos/owner/repo/releases', + ) + assert.equal( + cfg.tagApiUrl, + 'https://gitea.example.com/api/v1/repos/owner/repo/tags', + ) +}) + +test('loadConfig 在提供 token 時保留 token 值', () => { + const cfg = loadConfig({ ...base, GITEA_TOKEN: 'secret' }) + assert.equal(cfg.token, 'secret') +}) diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js index 1a70a9c..c6b82c7 100644 --- a/app/test/gitea-client.test.js +++ b/app/test/gitea-client.test.js @@ -1,4 +1,4 @@ -import { test, beforeEach, afterEach } from 'node:test' +import { test, afterEach } from 'node:test' import assert from 'node:assert/strict' import { GiteaClient } from '../gitea-client.js' @@ -8,6 +8,17 @@ afterEach(() => { globalThis.fetch = realFetch }) +// 建立模擬的 JSON 回應(含 content-type 標頭) +function jsonResponse(data) { + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => data, + text: async () => JSON.stringify(data), + } +} + test('建構子在有 token 時帶上 Authorization 標頭', () => { const client = new GiteaClient({ token: 'abc' }) assert.equal(client.headers.Authorization, 'token abc') @@ -28,7 +39,7 @@ test('fetchAllPages 逐頁讀取直到空陣列', async () => { globalThis.fetch = async (url) => { const page = new URL(url).searchParams.get('page') requested.push(page) - return { ok: true, json: async () => pages[page] } + return jsonResponse(pages[page]) } const client = new GiteaClient({}) @@ -41,8 +52,23 @@ test('fetchAllPages 逐頁讀取直到空陣列', async () => { assert.deepEqual(requested, ['1', '2', '3']) }) +test('fetchAllPages 帶上逾時 signal', async () => { + let sawSignal = false + globalThis.fetch = async (_url, opts) => { + sawSignal = opts && typeof opts.signal === 'object' && opts.signal !== null + return jsonResponse([]) + } + const client = new GiteaClient({}) + await client.fetchAllPages('https://example.com/api') + assert.equal(sawSignal, true) +}) + test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => { - globalThis.fetch = async () => ({ ok: false, status: 500 }) + globalThis.fetch = async () => ({ + ok: false, + status: 500, + text: async () => 'boom', + }) const client = new GiteaClient({}) await assert.rejects( () => client.fetchAllPages('https://example.com/api'), @@ -50,6 +76,20 @@ test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => { ) }) +test('fetchAllPages 在回應非 JSON 時丟出例外', async () => { + globalThis.fetch = async () => ({ + ok: true, + status: 200, + headers: { get: () => 'text/html' }, + text: async () => 'error', + }) + const client = new GiteaClient({}) + await assert.rejects( + () => client.fetchAllPages('https://example.com/api'), + /non-JSON content-type/, + ) +}) + test('deleteResource 回傳 HTTP 狀態碼', async () => { globalThis.fetch = async (url, opts) => { assert.equal(opts.method, 'DELETE') diff --git a/app/test/main.test.js b/app/test/main.test.js new file mode 100644 index 0000000..00e8d74 --- /dev/null +++ b/app/test/main.test.js @@ -0,0 +1,43 @@ +import { test, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { main } from '../index.js' + +const realFetch = globalThis.fetch +const savedEnv = { ...process.env } + +afterEach(() => { + globalThis.fetch = realFetch + process.env = { ...savedEnv } +}) + +function setEnv(env) { + for (const key of ['GITEA_SERVER_URL', 'GITEA_REPOSITORY', 'KEEP_COUNT', 'GITEA_TOKEN']) { + delete process.env[key] + } + Object.assign(process.env, env) +} + +test('main 在環境變數有效且 API 正常時順利完成', async () => { + setEnv({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + KEEP_COUNT: '2', + }) + // 所有清單端點皆回空陣列(無 release、無 tag) + globalThis.fetch = async () => ({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => [], + }) + + await main() // 不應拋出 +}) + +test('main 在必填環境變數缺失時 reject(供整合層級驗證錯誤處理)', async () => { + setEnv({ + GITEA_REPOSITORY: 'owner/repo', + KEEP_COUNT: '2', + }) + await assert.rejects(() => main(), /GITEA_SERVER_URL is required/) +}) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index b438545..6c88821 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { selectReleasesToDelete } from '../releases.js' +import { selectReleasesToDelete, cleanupReleases } from '../releases.js' const releases = [ { id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' }, @@ -32,3 +32,74 @@ test('selectReleasesToDelete 不改動原陣列', () => { selectReleasesToDelete(releases, 1) assert.deepEqual(releases, original) }) + +test('selectReleasesToDelete 對無效 created_at 視為最舊,不產生 NaN 排序', () => { + const list = [ + { id: 1, tag_name: 'good', created_at: '2024-05-01T00:00:00Z' }, + { id: 2, tag_name: 'bad', created_at: 'not-a-date' }, + { id: 3, tag_name: 'empty' }, + ] + const toDelete = selectReleasesToDelete(list, 1) + // 最新的 good 被保留;兩筆無效日期視為最舊,落入待刪除 + assert.equal(toDelete.length, 2) + assert.ok(!toDelete.some((r) => r.tag_name === 'good')) +}) + +// 建立可記錄刪除呼叫的假 client +function fakeClient(releases, statusFor = () => 204) { + const deleted = [] + return { + deleted, + async fetchAllPages() { + return releases + }, + async deleteResource(url) { + deleted.push(url) + return statusFor(url) + }, + } +} + +test('cleanupReleases 刪除超出保留數的舊 release', async () => { + const client = fakeClient([ + { id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + { id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, + { id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, + ]) + await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) + // 保留最新 v3,刪除 v2 與 v1 + assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1']) +}) + +test('cleanupReleases 在未超過保留數時不刪除', async () => { + const client = fakeClient([ + { id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + ]) + await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 2 }) + assert.equal(client.deleted.length, 0) +}) + +test('cleanupReleases 在 deleteResource 回傳非 204 時仍繼續處理其餘項目', async () => { + const client = fakeClient( + [ + { id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + { id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, + { id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, + ], + (url) => (url.endsWith('/2') ? 500 : 204), // v2 刪除失敗 + ) + await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) + // 即使 v2 失敗(500),v1 仍被嘗試刪除 + assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1']) +}) + +test('cleanupReleases 略過沒有 id 的 release', async () => { + const client = fakeClient([ + { id: 1, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, + { id: null, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + { id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, + ]) + await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) + // 保留最新 v3;待刪除為 v2 與 (無 id 的) v1 → 無 id 者被略過 + assert.deepEqual(client.deleted, ['http://x/releases/2']) +}) diff --git a/app/test/tags.test.js b/app/test/tags.test.js index d88abb2..debbc1f 100644 --- a/app/test/tags.test.js +++ b/app/test/tags.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { categorizeTags } from '../tags.js' +import { categorizeTags, cleanupOrphanTags } from '../tags.js' const tags = [ { name: 'v3.0.0' }, @@ -38,3 +38,51 @@ test('categorizeTags 在沒有任何 release 指定時刪除所有具名 tag', ( ['v3.0.0', 'v2.0.0', 'v1.0.0'], ) }) + +// 依 URL 回傳對應清單的假 client(releases 與 tags 兩種端點) +function fakeClient({ releases, tagList }) { + const deleted = [] + return { + deleted, + async fetchAllPages(url) { + return url.includes('/releases') ? releases : tagList + }, + async deleteResource(url) { + deleted.push(url) + return 204 + }, + } +} + +const config = { + releaseApiUrl: 'http://x/releases', + tagApiUrl: 'http://x/tags', +} + +test('cleanupOrphanTags 刪除孤立 tag、保留被指定的、略過無名稱', async () => { + const client = fakeClient({ + releases: [{ tag_name: 'v2.0.0' }], + tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }, { name: '' }], + }) + await cleanupOrphanTags(client, config) + // v2.0.0 仍被 release 指定 → 保留;v1.0.0 孤立 → 刪除;'' 無名稱 → 略過 + assert.deepEqual(client.deleted, ['http://x/tags/v1.0.0']) +}) + +test('cleanupOrphanTags 在所有 tag 都被指定時不刪除', async () => { + const client = fakeClient({ + releases: [{ tag_name: 'v2.0.0' }, { tag_name: 'v1.0.0' }], + tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }], + }) + await cleanupOrphanTags(client, config) + assert.equal(client.deleted.length, 0) +}) + +test('cleanupOrphanTags 在沒有任何 release 時刪除所有具名 tag', async () => { + const client = fakeClient({ + releases: [], + tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }], + }) + await cleanupOrphanTags(client, config) + assert.deepEqual(client.deleted, ['http://x/tags/v2.0.0', 'http://x/tags/v1.0.0']) +}) diff --git a/app/test/validate.test.js b/app/test/validate.test.js index d4b39d9..b715c7d 100644 --- a/app/test/validate.test.js +++ b/app/test/validate.test.js @@ -1,6 +1,12 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { isEmptyOrNull, requireValue, requireInteger } from '../validate.js' +import { + isEmptyOrNull, + requireValue, + requireInteger, + requireUrl, + requireRepository, +} from '../validate.js' test('isEmptyOrNull 對空值回傳 true', () => { assert.equal(isEmptyOrNull(''), true) @@ -35,3 +41,26 @@ test('requireInteger 拒絕非整數或負數', () => { assert.throws(() => requireInteger('KEEP_COUNT', '1.5'), /non-negative integer/) assert.throws(() => requireInteger('KEEP_COUNT', 'abc'), /non-negative integer/) }) + +test('requireUrl 接受 http/https URL', () => { + assert.doesNotThrow(() => requireUrl('URL', 'https://gitea.example.com')) + assert.doesNotThrow(() => requireUrl('URL', 'http://localhost:3000')) +}) + +test('requireUrl 拒絕非法 URL 或非 http(s) 協定', () => { + assert.throws(() => requireUrl('URL', 'not a url'), /must be a valid URL/) + assert.throws(() => requireUrl('URL', 'ftp://example.com'), /http or https/) + assert.throws(() => requireUrl('URL', 'file:///etc/passwd'), /http or https/) +}) + +test('requireRepository 接受合法 owner/repo', () => { + assert.doesNotThrow(() => requireRepository('REPO', 'owner/repo')) + assert.doesNotThrow(() => requireRepository('REPO', 'my-org/my_repo.git')) +}) + +test('requireRepository 拒絕路徑穿越或格式錯誤', () => { + assert.throws(() => requireRepository('REPO', '../evil'), /owner\/repo/) + assert.throws(() => requireRepository('REPO', 'owner/repo/extra'), /owner\/repo/) + assert.throws(() => requireRepository('REPO', 'noslash'), /owner\/repo/) + assert.throws(() => requireRepository('REPO', 'owner/..'), /owner\/repo/) +}) -- 2.53.0 From a95945262d73962fa896805aacca3b238e0af5c0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:55:45 +0800 Subject: [PATCH 09/43] =?UTF-8?q?docs(release-cleanup):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README=20=E5=8A=9F=E8=83=BD=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E8=88=87=20Dockerfile=20=E8=A8=BB=E8=A7=A3=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 新增 requireUrl/requireRepository、修正原始碼連結行號與更新時間; Dockerfile 調整註解冒號/括號前的空白(無邏輯變更)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 4 ++-- README.md | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2984be7..554f4da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,10 @@ # 更新日期: 2026/06/26 10:28:36 # ============================================================================= -# 基底映像:Node.js 20 的 Alpine 版本 (體積小)。 +# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。 FROM node:20-alpine -# 複製 Node.js 應用程式(不含測試) +# 複製 Node.js 應用程式 (不含測試) # 先單獨複製 package.json,再複製所有 *.js;有利於 Docker layer 快取。 COPY app/package.json /app/package.json COPY app/*.js /app/ diff --git a/README.md b/README.md index 00a76ed..5d10c87 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。 -> 更新時間:2026/06/26 10:40:15 +> 更新時間:2026/06/26 10:54:59 ## 專案列表 @@ -39,12 +39,14 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的 | [isEmptyOrNull](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L9) | [判斷值是否為空(空字串、null、undefined 或字串 "null")。](#isemptyornull) | | [requireValue](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L19) | [要求欄位有值,空值時丟出 Error。](#requirevalue) | | [requireInteger](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L32) | [要求欄位為非負整數,否則丟出 Error。](#requireinteger) | -| [loadConfig](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/config.js#L20) | [從環境變數載入並驗證設定,回傳設定物件。](#loadconfig) | -| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L8) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | -| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L31) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | -| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L60) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | +| [requireUrl](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L45) | [要求欄位為合法的 http/https URL。](#requireurl) | +| [requireRepository](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L64) | [要求欄位為合法的 owner/repo 形式且無路徑穿越。](#requirerepository) | +| [loadConfig](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/config.js#L26) | [從環境變數載入並驗證設定,回傳設定物件。](#loadconfig) | +| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L11) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | +| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L34) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | +| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L78) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | | [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L14) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) | -| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L32) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | +| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L35) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | | [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L17) | [將 tag 分類為保留、刪除或略過。](#categorizetags) | | [cleanupOrphanTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L42) | [刪除未被任何 release 指定的孤立 tag。](#cleanuporphantags) | @@ -165,6 +167,31 @@ requireInteger('KEEP_COUNT', 0) // 通過 requireInteger('KEEP_COUNT', '-1') // 丟出 Error: KEEP_COUNT must be a non-negative integer ``` + +### requireUrl + +要求欄位為合法的 http/https URL,避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。 + +```js +import { requireUrl } from './validate.js' + +requireUrl('GITEA_SERVER_URL', 'https://gitea.example.com') // 通過 +requireUrl('GITEA_SERVER_URL', 'ftp://x') // 丟出 Error: ... must use http or https protocol +requireUrl('GITEA_SERVER_URL', 'not a url') // 丟出 Error: ... must be a valid URL +``` + + +### requireRepository + +要求欄位為合法的 `owner/repo` 形式(僅允許英數字與 `. _ -`、恰好一個 `/`),並拒絕含 `..` 的路徑穿越輸入。 + +```js +import { requireRepository } from './validate.js' + +requireRepository('GITEA_REPOSITORY', 'owner/repo') // 通過 +requireRepository('GITEA_REPOSITORY', '../evil') // 丟出 Error: ... owner/repo without path traversal +``` + ### loadConfig -- 2.53.0 From 245f66de8f4eefbfc282dce0d0106f660ae7021a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:55:45 +0800 Subject: [PATCH 10/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除已修復的 13 條 finding;保留 4 條設計取捨類(重試/TOCTOU/並行化) 待人工評估;將 token log 排版建議登記為誤報於 exclusions.json。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 8 ++ .gitea/ai-review/findings.json | 144 +++++-------------------------- 2 files changed, 28 insertions(+), 124 deletions(-) create mode 100644 .gitea/ai-review/exclusions.json diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json new file mode 100644 index 0000000..8d49f39 --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,8 @@ +[ + { + "location": "app/config.js:31", + "role": "Bard", + "original_finding": "日志信息的键值对缺少空格,排版不够整齐统一。建议加上空格以提升易读性:`info('GITEA_TOKEN = [redacted]')`", + "reason": "誤報。loadConfig 中所有參數記錄皆採一致的 `KEY=value` 格式(如 GITEA_SERVER_URL=...、KEEP_COUNT=...);僅將 GITEA_TOKEN 一行改為 `KEY = value` 反而破壞整體一致性,故不採納。" + } +] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 594dba5..b14d0d5 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,146 +1,42 @@ [ - { - "level": "critical", - "role": "Assassin", - "location": "app/config.js:21", - "problem": "GITEA_SERVER_URL 缺乏嚴格的格式驗證與協定強制。攻擊者若控制此環境變數,可將其設定為任意惡意伺服器(例如 http://attacker.com),引發 SSRF 攻擊或在沒有 HTTPS 的情況下,導致傳輸過程中 GITEA_TOKEN 被竊取。", - "suggestion": "應使用 Node.js 的 URL 類別驗證 GITEA_SERVER_URL 格式是否合法,並強制使用 HTTPS 協定。若可能,建議實作 allowed host 的白名單機制。", - "is_new": true - }, - { - "level": "critical", - "role": "Assassin", - "location": "app/config.js:39", - "problem": "GITEA_REPOSITORY 變數直接被拼接進 API URL 中,卻未進行任何消毒。攻擊者可利用 Path Traversal(例如設定為 ../../other-repo)繞過權限,導致 API 呼叫目標被竄改,進而執行未授權的動作。", - "suggestion": "必須對 GITEA_REPOSITORY 進行格式驗證,僅允許合法的 Repository 名稱字元(例如英數字、`-`、`_`、`/`),且必須拒絕包含 `..` 或其他路徑穿越字元的輸入。", - "is_new": true - }, - { - "level": "critical", - "role": "Mage", - "location": "app/gitea-client.js:31", - "problem": "在 `fetchAllPages` 的 `while` 迴圈中直接使用 `await fetch` 而沒有設定逾時(timeout)。若 Gitea API 伺服器因負載過重導致回應緩慢或掛起,該容器進程將會永久卡死,無法釋放資源或失敗重試。", - "suggestion": "建議使用 `AbortController` 設定合理的逾時時間(例如:`{ signal: AbortSignal.timeout(30000) }`),確保網路呼叫在預期時間內未完成時能主動拋出例外。", - "is_new": true - }, - { - "level": "critical", - "role": "Mage", - "location": "app/index.js:26", - "problem": "當 `main().catch(...)` 捕捉到錯誤並執行 `process.exit(1)` 時,僅輸出 `error.message`。若錯誤是由 `fetch` 網路層拋出(例如 DNS 解析失敗或 connection refused),Node.js 的 `Error` 物件可能不包含足夠的上下文訊息,導致使用者難以區分是 API 回傳錯誤還是程式碼執行期錯誤。", - "suggestion": "建議在 `catch` 區塊中,若錯誤是 `Error` 物件,輸出 `error.stack` 或至少記錄錯誤類型,並區分不同層級的例外(例如:驗證錯誤 vs. API 請求錯誤)。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/releases.js:21", - "problem": "cleanupReleases 是執行刪除舊成品的核心函式,但目前缺乏針對 API 呼叫失敗(如 GET 失敗、DELETE 失敗)的測試,無法驗證錯誤處理邏輯是否如預期運作。", - "suggestion": "使用測試框架搭配 mock 伺服器回應,補齊 cleanupReleases 的測試案例,特別是驗證當 deleteResource 回傳非 204 狀態碼時,系統是否正確記錄錯誤並繼續或中斷。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/tags.js:35", - "problem": "cleanupOrphanTags 涉及多次 API 交互,目前缺乏測試驗證邏輯,無法確保在 API 請求失敗或標籤分類錯誤時系統的行為一致性。", - "suggestion": "補齊 cleanupOrphanTags 的測試案例,需模擬 fetchAllPages 回傳內容,並驗證對於不同 action(keep/delete/skip)的處理流程是否正確。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:14", - "problem": "使用 `new Date(release.created_at)` 進行排序時,若 `created_at` 格式非預期或為空,會導致 `NaN` 並造成排序異常,可能無法正確刪除舊版本。", - "suggestion": "建議在排序邏輯中增加對 `created_at` 的有效性檢查,若無效則給予預設值(例如:`new Date(0)`),確保排序結果可預期。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/gitea-client.js:25", - "problem": "HTTP 請求 (fetch) 未設定逾時時間,若 API 伺服器回應緩慢或網路阻塞,可能導致此 Actions 執行過程長時間卡死,造成除錯與排程上的困難。", - "suggestion": "建議使用 `AbortController` 為所有 `fetch` 請求設定合理的逾時限制(例如 30 秒),並正確處理 `AbortError`。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/gitea-client.js:28", - "problem": "在 `fetchAllPages` 中未檢查 API 回傳的內容是否符合預期格式(除了 Array 檢查)。若 API 回傳非 JSON 格式的內容(例如 HTML 錯誤頁面),`res.json()` 會拋出 SyntaxError,且未被目前邏輯中的 `try-catch` 明確攔截處理,會導致程式在 catch 區塊中直接結束並輸出錯誤訊息,缺乏更細緻的除錯資訊。", - "suggestion": "在解析 JSON 前,應先判斷 `res.headers.get('content-type')` 是否包含 `application/json`,若非 JSON,應將 `res.text()` 的內容一併在錯誤訊息中輸出,方便排查。", - "is_new": true - }, { "level": "warning", "role": "Mage", "location": "app/releases.js:46", - "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 `fail` 並繼續執行,可能導致後續刪除邏輯的不一致。", - "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。", - "is_new": true + "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 `fail` 並繼續執行,可能導致後續刪除邏輯的不一致。", + "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。", + "is_new": true, + "status": "deferred", + "defer_reason": "屬功能性增強與設計取捨。此清理 Action 通常以排程執行,單次失敗可於下次執行補刪;是否引入重試/退避涉及重試次數、間隔與冪等性等設計決策,保留待人工評估。" }, { "level": "warning", "role": "Mage", "location": "app/tags.js:56", - "problem": "在 `cleanupOrphanTags` 中,雖然先重新 fetch 了 release 清單,但 `cleanupReleases` 和 `cleanupOrphanTags` 是非同步執行,且中間無確保一致性的機制。若在 `cleanupReleases` 刪除完成後到 `cleanupOrphanTags` 執行期間,Gitea 上有新的 release 被建立,則 `releaseTagNames` 的快照將會過時,導致正在使用的 tag 被錯誤刪除。", - "suggestion": "考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/config.js:11", - "problem": "loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。", - "suggestion": "補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/index.js:17", - "problem": "main 函式作為整個流程的入口,缺乏端對端(E2E)或整合測試,無法確保各模組整合後在失敗情境下(如 process.exit(1) 的觸發)是否能正確運作。", - "suggestion": "針對 main 函式建立整合測試,至少驗證在拋出例外時,logger.fail 是否有被呼叫且程序是否以錯誤代碼結束。", - "is_new": true + "problem": "在 `cleanupOrphanTags` 中,雖然先重新 fetch 了 release 清單,但 `cleanupReleases` 和 `cleanupOrphanTags` 是非同步執行,且中間無確保一致性的機制。若在 `cleanupReleases` 刪除完成後到 `cleanupOrphanTags` 執行期間,Gitea 上有新的 release 被建立,則 `releaseTagNames` 的快照將會過時,導致正在使用的 tag 被錯誤刪除。", + "suggestion": "考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。", + "is_new": true, + "status": "deferred", + "defer_reason": "現行程式已在 cleanupOrphanTags 開頭重新抓取最新 release 清單作為主要緩解;殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本的設計取捨,保留待人工評估。" }, { "level": "warning", "role": "Rogue", "location": "app/releases.js:43", - "problem": "在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。", - "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。", - "is_new": true + "problem": "在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。", + "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。", + "is_new": true, + "status": "deferred", + "defer_reason": "刻意保留序列化:可避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序,且與原 bash 版本行為一致。無上限並行化非等價變更,保留待人工評估(可日後改為有上限的並行)。" }, { "level": "warning", "role": "Rogue", "location": "app/tags.js:46", - "problem": "同樣在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。", - "suggestion": "同樣改用 `Promise.all` 將刪除請求並行化,加快清理速度。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "Dockerfile:13", - "problem": "注释中的冒号后面缺少空格,阅读节奏感稍显拥挤。", - "suggestion": "请在冒号后面加上一个空格,让注释读起来更舒畅:`# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。`", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "Dockerfile:16", - "problem": "注释中括号内的说明与前文缺少空格区隔,排版不够优雅。", - "suggestion": "在括号前面增加一个空格:`# 複製 Node.js 應用程式 (不含測試)`", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "app/config.js:31", - "problem": "日志信息的键值对缺少空格,排版不够整齐统一。", - "suggestion": "建议加上空格以提升易读性:`info('GITEA_TOKEN = [redacted]')`", - "is_new": true + "problem": "同樣在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。", + "suggestion": "同樣改用 `Promise.all` 將刪除請求並行化,加快清理速度。", + "is_new": true, + "status": "deferred", + "defer_reason": "與 app/releases.js:43 同理,刻意保留序列化以避免併發壓力與速率限制並維持記錄順序,屬設計取捨,保留待人工評估。" } ] -- 2.53.0 From 1623408d029420306f9769b09f17c04a152b5f89 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 02:56:30 +0000 Subject: [PATCH 11/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/exclusions.json | 12 +++ .gitea/ai-review/findings.json | 154 ++++++++++++++++++++++++++++--- 2 files changed, 154 insertions(+), 12 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 8d49f39..327dc5e 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -4,5 +4,17 @@ "role": "Bard", "original_finding": "日志信息的键值对缺少空格,排版不够整齐统一。建议加上空格以提升易读性:`info('GITEA_TOKEN = [redacted]')`", "reason": "誤報。loadConfig 中所有參數記錄皆採一致的 `KEY=value` 格式(如 GITEA_SERVER_URL=...、KEEP_COUNT=...);僅將 GITEA_TOKEN 一行改為 `KEY = value` 反而破壞整體一致性,故不採納。" + }, + { + "location": "Dockerfile:13", + "role": "Bard", + "original_finding": "请在冒号后面加上一个空格,让注释读起来更舒畅:`# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。`", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "Dockerfile:16", + "role": "Bard", + "original_finding": "在括号前面增加一个空格:`# 複製 Node.js 應用程式 (不含測試)`", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index b14d0d5..d4ccfd8 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,11 +1,51 @@ [ + { + "level": "critical", + "role": "Maya", + "problem": "cleanupReleases 是執行刪除舊成品的核心函式,但目前缺乏針對 API 呼叫失敗(如 GET 失敗、DELETE 失敗)的測試,無法驗證錯誤處理邏輯是否如預期運作。", + "suggestion": "使用測試框架搭配 mock 伺服器回應,補齊 cleanupReleases 的測試案例,特別是驗證當 deleteResource 回傳非 204 狀態碼時,系統是否正確記錄錯誤並繼續或中斷。", + "location": "app/releases.js:21", + "is_new": false + }, + { + "level": "critical", + "role": "Maya", + "problem": "cleanupOrphanTags 涉及多次 API 交互,目前缺乏測試驗證邏輯,無法確保在 API 請求失敗或標籤分類錯誤時系統的行為一致性。", + "suggestion": "補齊 cleanupOrphanTags 的測試案例,需模擬 fetchAllPages 回傳內容,並驗證對於不同 action(keep/delete/skip)的處理流程是否正確。", + "location": "app/tags.js:35", + "is_new": false + }, + { + "level": "critical", + "role": "Bard", + "location": "app/gitea-client.js:59", + "problem": "在 `fetchAllPages` 中使用 `all.push(...items)` 展開處理分頁資料,若 API 回傳的項目數量龐大(例如數千筆),極可能觸發「超過呼叫堆疊最大長度」(Maximum call stack size exceeded)導致程式崩潰,這對長期運行的工具而言是一大隱憂。", + "suggestion": "建議改用簡單的迴圈 `for (const item of items) { all.push(item); }` 來逐一加入項目,這能完美規避展開運算子在處理大型陣列時的堆疊風險,讓程式運行得更加穩健優雅。", + "is_new": true + }, + { + "level": "critical", + "role": "Mage", + "location": "app/gitea-client.js:55", + "problem": "await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。", + "suggestion": "將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/index.js:46", + "problem": "專案雖然新增了 `app/test/` 資料夾與多個測試檔案,但目前沒看到任何整合測試,且 `app/index.js` 的 `main()` 邏輯未被驗證過,這意味著最核心的清理流程尚未受到測試保護。", + "suggestion": "請在 `app/test/` 中新增整合測試,模擬 `loadConfig` 回傳假設定後,確認 `cleanupReleases` 與 `cleanupOrphanTags` 有被正確呼叫(例如透過 mock GiteaClient),確保流程串接無誤。", + "is_new": true + }, { "level": "warning", "role": "Mage", "location": "app/releases.js:46", "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 `fail` 並繼續執行,可能導致後續刪除邏輯的不一致。", "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。", - "is_new": true, + "is_new": false, "status": "deferred", "defer_reason": "屬功能性增強與設計取捨。此清理 Action 通常以排程執行,單次失敗可於下次執行補刪;是否引入重試/退避涉及重試次數、間隔與冪等性等設計決策,保留待人工評估。" }, @@ -15,7 +55,7 @@ "location": "app/tags.js:56", "problem": "在 `cleanupOrphanTags` 中,雖然先重新 fetch 了 release 清單,但 `cleanupReleases` 和 `cleanupOrphanTags` 是非同步執行,且中間無確保一致性的機制。若在 `cleanupReleases` 刪除完成後到 `cleanupOrphanTags` 執行期間,Gitea 上有新的 release 被建立,則 `releaseTagNames` 的快照將會過時,導致正在使用的 tag 被錯誤刪除。", "suggestion": "考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。", - "is_new": true, + "is_new": false, "status": "deferred", "defer_reason": "現行程式已在 cleanupOrphanTags 開頭重新抓取最新 release 清單作為主要緩解;殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本的設計取捨,保留待人工評估。" }, @@ -23,20 +63,110 @@ "level": "warning", "role": "Rogue", "location": "app/releases.js:43", - "problem": "在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。", - "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。", - "is_new": true, - "status": "deferred", - "defer_reason": "刻意保留序列化:可避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序,且與原 bash 版本行為一致。無上限並行化非等價變更,保留待人工評估(可日後改為有上限的並行)。" + "problem": "在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。", + "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。" }, { "level": "warning", "role": "Rogue", "location": "app/tags.js:46", - "problem": "同樣在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。", - "suggestion": "同樣改用 `Promise.all` 將刪除請求並行化,加快清理速度。", - "is_new": true, - "status": "deferred", - "defer_reason": "與 app/releases.js:43 同理,刻意保留序列化以避免併發壓力與速率限制並維持記錄順序,屬設計取捨,保留待人工評估。" + "problem": "在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。", + "suggestion": "改用 `Promise.all` 將刪除請求並行化,加快清理速度。" + }, + { + "level": "warning", + "role": "Maya", + "problem": "loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。", + "suggestion": "補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。", + "location": "app/config.js:11", + "is_new": false + }, + { + "level": "warning", + "role": "Mage", + "problem": "在 `fetchAllPages` 中未檢查 API 回傳的內容是否符合預期格式(除了 Array 檢查)。若 API 回傳非 JSON 格式的內容(例如 HTML 錯誤頁面),`res.json()` 會拋出 SyntaxError,且未被目前邏輯中的 `try-catch` 明確攔截處理,會導致程式在 catch 區塊中直接結束並輸出錯誤訊息,缺乏更細緻的除錯資訊。", + "suggestion": "在解析 JSON 前,應先判斷 `res.headers.get('content-type')` 是否包含 `application/json`,若非 JSON,應將 `res.text()` 的內容一併在錯誤訊息中輸出,方便排查。", + "location": "app/gitea-client.js:28", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/gitea-client.js:48", + "problem": "將 HTTP 錯誤回應內容不加淨化地直接寫入錯誤訊息。攻擊者可能構造特殊的錯誤回應,透過 log 注入或後續錯誤處理機制進行惡意利用。", + "suggestion": "錯誤訊息應限制內容長度(已做到),建議進一步剝離 HTML 標籤,或僅記錄必要的狀態碼與錯誤類型,避免直接紀錄原始回應內容。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/gitea-client.js:67", + "problem": "將 Content-Type 不符的錯誤內容直接寫入錯誤訊息。同樣面臨 log 注入或惡意回應內容的問題。", + "suggestion": "同上,建議精簡錯誤資訊,移除可能包含惡意 payload 的回應 body 部分。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/releases.js:37", + "problem": "這裡的 `cleanupReleases` 函式同時負責了「取得資料」、「判斷邏輯」與「執行副作用(刪除)」三種責任,這違反了單一職責原則(SRP),隨著 API 呼叫變複雜,這會導致未來很難單獨測試刪除邏輯。", + "suggestion": "建議將 `cleanupReleases` 拆分為「取得成品與過濾需要刪除的成品」的純邏輯層,以及「執行刪除請求」的執行層。目前的 `selectReleasesToDelete` 已經做了一部分,建議把迴圈刪除的部分也封裝成一個負責執行動作的函式。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/tags.js:46", + "problem": "在 `cleanupOrphanTags` 中,直接在主流程中遍歷並執行刪除,同樣面臨未來如果需要對刪除失敗進行更複雜的處理(例如重試、批次處理)時,邏輯會變得難以維護。", + "suggestion": "建議將 `cleanupOrphanTags` 參考 `cleanupReleases` 的結構,將「分類與過濾」與「執行刪除動作」進一步解耦,並對每個 tag 的刪除動作進行更細緻的錯誤處理。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/gitea-client.js:54", + "problem": "fetchAllPages 的迴圈終止條件僅檢查 `!Array.isArray(items) || items.length === 0`。若 API 因為某種狀況(如認證過期或 API 內部錯誤)回傳了非陣列格式的 JSON 錯誤訊息,會直接視為「最後一頁」而提前終止迴圈,導致回傳不完整的清單且未報錯。", + "suggestion": "應在確認 res.ok 為 true 的前提下,嚴格要求 items 為陣列。若 res.ok 為 true 但 items 不為陣列,應拋出錯誤而非將其視為終止訊號。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/gitea-client.js:33", + "problem": "`fetchAllPages` 使用了 `AbortSignal.timeout(REQUEST_TIMEOUT_MS)`,但在測試 `app/test/gitea-client.js` 時,並未測試過「網路逾時」情境。", + "suggestion": "在 `app/test/gitea-client.js` 中增加一個測試案例,模擬 fetch 函數直接拋出 `AbortError`,驗證 `fetchAllPages` 能否正確處理該錯誤,而不是讓容器無預警崩潰。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/config.js:36", + "problem": "參數 `GITEA_TOKEN` 若提供,在 `loadConfig` 中只會檢查其是否為空,且為了資安會遮蔽輸出。這很棒,但缺乏對 `GITEA_TOKEN` 格式或長度的邊界測試。", + "suggestion": "在 `app/test/config.test.js` 中增加測試案例,針對 `GITEA_TOKEN` 為極短字串(如空字串、單一字元)或極長字串進行測試,確認系統行為符合預期。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/index.js:34", + "problem": "`reportFatal` 直接操作 `process.stderr` 並在 `main().catch` 中手動調用,這與 `logger.js` 中定義的 `fail` 函式職責重疊,這會在未來增加日誌格式變更的維護成本。", + "suggestion": "建議直接呼叫 `fail` 函式,並在 `logger.js` 中根據錯誤類型(是否為 Error 物件)自動處理堆疊追蹤資訊,將「如何輸出錯誤」的邏輯全部收斂至 `logger.js`。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/validate.js:47", + "problem": "雖然驗證邏輯完整,但 `requireUrl` 和 `requireRepository` 的規則直接寫死在函式內。如果未來有其他的 API 端點需要不同的驗證規則,會產生大量重複程式碼。", + "suggestion": "考慮將驗證規則(Regex 或協定清單)提取為設定物件或共用常數,這能讓維護者一眼看出系統允許的 URL 限制,方便未來擴充。", + "is_new": true + }, + { + "level": "info", + "role": "Rogue", + "location": "app/gitea-client.js:33", + "problem": "`fetchAllPages` 採取線性逐頁請求,資料量龐大時會導致總請求時間過長。", + "suggestion": "若 API 支援並行讀取,建議先取得總頁數並行發出請求,而非序列式逐頁讀取。", + "is_new": true } ] -- 2.53.0 From 0f4354bd011c1b2f94db90919f0201e4f97460bb Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:04:29 +0800 Subject: [PATCH 12/43] =?UTF-8?q?fix(gitea-client):=20=E5=BC=B7=E5=8C=96?= =?UTF-8?q?=E5=88=86=E9=A0=81=E5=9B=9E=E6=87=89=E7=9A=84=20JSON=20?= =?UTF-8?q?=E8=88=87=E9=8C=AF=E8=AA=A4=E5=85=A7=E5=AE=B9=E8=99=95=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - res.json() 以 try-catch 包裹,解析失敗時補上 URL 上下文 - res.ok 為真時嚴格要求陣列,非陣列回應改為拋錯而非靜默結束 - 改用迴圈逐一 push 取代展開運算子,避免大量項目的堆疊風險 - 錯誤訊息中的回應內容經 sanitize(移除控制字元)防止 log 注入 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/gitea-client.js | 47 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/app/gitea-client.js b/app/gitea-client.js index 6f6ce8e..f54fdd8 100644 --- a/app/gitea-client.js +++ b/app/gitea-client.js @@ -4,6 +4,18 @@ // 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。 const REQUEST_TIMEOUT_MS = 30000 +/** + * 將回應內容整理成可安全寫入錯誤訊息的片段:移除控制字元(避免換行等造成的 log 注入)並限制長度。 + * @param {string} text 原始回應文字 + * @returns {string} 清理後、最長 200 字元的片段 + */ +function sanitizeBody(text) { + return (text || '') + .replace(/[\u0000-\u001F\u007F]+/g, ' ') + .trim() + .slice(0, 200) +} + /** * 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。 * 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。 @@ -22,14 +34,15 @@ export class GiteaClient { } /** - * 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列(或非陣列)為止, + * 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列為止, * 將所有頁面項目合併成單一陣列回傳。 * - * 注意:終止條件依賴「空陣列代表最後一頁」的假設;若 API 不以空陣列結尾則迴圈不會自然停止。 + * 在 `res.ok` 為真的前提下嚴格要求回應為 JSON 陣列:非 JSON、JSON 解析失敗或非陣列內容 + * 都會拋出帶 URL 上下文的錯誤,而非被誤判為最後一頁而靜默結束。 * * @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`) * @returns {Promise} 所有頁面合併後的項目陣列;無資料時為空陣列 - * @throws {Error} 任一頁回應 HTTP 非 2xx(`!res.ok`)時拋出,訊息含 URL 與狀態碼 + * @throws {Error} HTTP 非 2xx、回應非 JSON、JSON 無法解析或非陣列、或請求逾時(AbortError)時拋出 */ async fetchAllPages(baseUrl) { const all = [] @@ -43,27 +56,41 @@ export class GiteaClient { }) if (!res.ok) { - const body = await res.text().catch(() => '') + const body = sanitizeBody(await res.text().catch(() => '')) throw new Error( - `GET ${url} failed: HTTP ${res.status}${body ? ` - ${body.slice(0, 200)}` : ''}`, + `GET ${url} failed: HTTP ${res.status}${body ? ` - ${body}` : ''}`, ) } // 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。 const contentType = res.headers.get('content-type') || '' if (!contentType.includes('application/json')) { - const body = await res.text().catch(() => '') + const body = sanitizeBody(await res.text().catch(() => '')) throw new Error( - `GET ${url} returned non-JSON content-type "${contentType}": ${body.slice(0, 200)}`, + `GET ${url} returned non-JSON content-type "${contentType}"${body ? `: ${body}` : ''}`, ) } - const items = await res.json() - if (!Array.isArray(items) || items.length === 0) { + // 即使 content-type 正確,內容仍可能格式錯誤;明確攔截 SyntaxError 並補上 URL 上下文。 + let items + try { + items = await res.json() + } catch (error) { + throw new Error(`GET ${url} returned invalid JSON: ${error.message}`) + } + + // res.ok 為真時嚴格要求陣列:非陣列代表非預期回應(如錯誤物件),應報錯而非視為最後一頁。 + if (!Array.isArray(items)) { + throw new Error(`GET ${url} returned a non-array JSON payload`) + } + if (items.length === 0) { break } - all.push(...items) + // 逐一加入(不使用展開運算子),避免大量項目時觸發呼叫堆疊上限。 + for (const item of items) { + all.push(item) + } page += 1 } -- 2.53.0 From f6d2ebeab20cb56046c386e6833db28c0e28324f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:04:29 +0800 Subject: [PATCH 13/43] =?UTF-8?q?refactor(release-cleanup):=20=E6=94=B6?= =?UTF-8?q?=E6=96=82=E9=8C=AF=E8=AA=A4=E8=BC=B8=E5=87=BA=E4=B8=A6=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=E9=A9=97=E8=AD=89=E8=A6=8F=E5=89=87=E5=B8=B8=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 logger.failError 集中處理 Error/非 Error 的 stderr 輸出與堆疊, index.js 改用之,移除重複的 reportFatal - validate.js 將允許協定與 repository 格式抽為模組常數,便於維護擴充 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/index.js | 20 ++------------------ app/logger.js | 17 +++++++++++++++++ app/validate.js | 9 +++++++-- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/index.js b/app/index.js index ec5c3fb..19044ed 100644 --- a/app/index.js +++ b/app/index.js @@ -4,7 +4,7 @@ import { loadConfig } from './config.js' import { GiteaClient } from './gitea-client.js' import { cleanupReleases } from './releases.js' import { cleanupOrphanTags } from './tags.js' -import { separator, fail } from './logger.js' +import { separator, failError } from './logger.js' /** * Action 主流程:讀取並驗證環境設定、建立帶認證的 Gitea 客戶端, @@ -24,26 +24,10 @@ export async function main() { separator() } -/** - * 將錯誤輸出至 stderr,並盡量保留可供除錯的上下文(錯誤類型與堆疊), - * 以便區分設定驗證錯誤與網路/API 請求錯誤。 - * @param {unknown} error 捕捉到的錯誤 - */ -function reportFatal(error) { - if (error instanceof Error) { - fail(`${error.name}: ${error.message}`) - if (error.stack) { - process.stderr.write(`${error.stack}\n`) - } - } else { - fail(String(error)) - } -} - // 僅在直接以 `node index.js` 執行時啟動主流程;被測試 import 時不自動執行,方便撰寫整合測試。 if (import.meta.url === `file://${process.argv[1]}`) { main().catch((error) => { - reportFatal(error) + failError(error) process.exit(1) }) } diff --git a/app/logger.js b/app/logger.js index 4db97f1..c421c05 100644 --- a/app/logger.js +++ b/app/logger.js @@ -51,3 +51,20 @@ export function warn(message) { export function fail(message) { process.stderr.write(`[ERR] ${message}\n`) } + +/** + * 輸出錯誤至 stderr,並依錯誤型別保留可供除錯的上下文: + * 若為 Error 物件,輸出「名稱: 訊息」並附上堆疊;否則輸出其字串形式。 + * 將「如何輸出錯誤」的邏輯集中於此,呼叫端毋須自行操作 stderr 或判斷型別。 + * @param {unknown} error 待輸出的錯誤 + */ +export function failError(error) { + if (error instanceof Error) { + fail(`${error.name}: ${error.message}`) + if (error.stack) { + process.stderr.write(`${error.stack}\n`) + } + } else { + fail(String(error)) + } +} diff --git a/app/validate.js b/app/validate.js index 7c2ee95..dec84d3 100644 --- a/app/validate.js +++ b/app/validate.js @@ -1,6 +1,11 @@ // 參數驗證,對應原本 entrypoint.sh 的 is_empty_or_null/require_value/require_integer。 // 驗證失敗時丟出 Error,由進入點統一捕捉後以非零狀態結束。 +// 允許的 URL 協定;集中為常數,方便檢視系統允許的協定與日後擴充。 +const ALLOWED_URL_PROTOCOLS = ['http:', 'https:'] +// 合法 repository 形式:owner/repo,僅允許英數字與 . _ -,且恰好一個 /。 +const REPOSITORY_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/ + /** * 判斷值是否視為「空」。使用嚴格相等,因此 `0`、`false`、字串 `"0"` 都不算空。 * @param {*} value 待判斷的值 @@ -49,7 +54,7 @@ export function requireUrl(name, value) { } catch { throw new Error(`${name} must be a valid URL`) } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { + if (!ALLOWED_URL_PROTOCOLS.includes(url.protocol)) { throw new Error(`${name} must use http or https protocol`) } } @@ -62,7 +67,7 @@ export function requireUrl(name, value) { * @throws {Error} 格式不符或含 `..` 時丟出 `${name} must be in the form owner/repo without path traversal` */ export function requireRepository(name, value) { - if (String(value).includes('..') || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)) { + if (String(value).includes('..') || !REPOSITORY_PATTERN.test(value)) { throw new Error(`${name} must be in the form owner/repo without path traversal`) } } -- 2.53.0 From b9d7c4e97038bb431d2273ba89e82dff6f50ee02 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:04:29 +0800 Subject: [PATCH 14/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E5=BC=B7=E9=80=BE=E6=99=82=E3=80=81=E9=9D=9E=E9=99=A3=E5=88=97?= =?UTF-8?q?=E3=80=81token=20=E9=82=8A=E7=95=8C=E8=88=87=20failError=20?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 fetchAllPages 的 AbortError 逾時與非陣列回應測試、loadConfig 的 GITEA_TOKEN 邊界測試,以及 logger.failError 測試。測試共 47 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/config.test.js | 14 ++++++++++++++ app/test/gitea-client.test.js | 22 ++++++++++++++++++++++ app/test/logger.test.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 app/test/logger.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js index cd1975c..4cea9e7 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -56,3 +56,17 @@ test('loadConfig 在提供 token 時保留 token 值', () => { const cfg = loadConfig({ ...base, GITEA_TOKEN: 'secret' }) assert.equal(cfg.token, 'secret') }) + +test('loadConfig 在 GITEA_TOKEN 為空字串時視為匿名(token 為 null)', () => { + const cfg = loadConfig({ ...base, GITEA_TOKEN: '' }) + assert.equal(cfg.token, null) +}) + +test('loadConfig 接受極短與極長的 GITEA_TOKEN', () => { + const short = loadConfig({ ...base, GITEA_TOKEN: 'x' }) + assert.equal(short.token, 'x') + + const longToken = 'a'.repeat(1000) + const long = loadConfig({ ...base, GITEA_TOKEN: longToken }) + assert.equal(long.token, longToken) +}) diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js index c6b82c7..423ad4b 100644 --- a/app/test/gitea-client.test.js +++ b/app/test/gitea-client.test.js @@ -90,6 +90,28 @@ test('fetchAllPages 在回應非 JSON 時丟出例外', async () => { ) }) +test('fetchAllPages 在回應為非陣列 JSON 時丟出例外', async () => { + globalThis.fetch = async () => jsonResponse({ message: 'error object' }) + const client = new GiteaClient({}) + await assert.rejects( + () => client.fetchAllPages('https://example.com/api'), + /non-array JSON payload/, + ) +}) + +test('fetchAllPages 在 fetch 因逾時拋出 AbortError 時向外拋出', async () => { + globalThis.fetch = async () => { + const err = new Error('The operation was aborted due to timeout') + err.name = 'AbortError' + throw err + } + const client = new GiteaClient({}) + await assert.rejects( + () => client.fetchAllPages('https://example.com/api'), + (err) => err.name === 'AbortError', + ) +}) + test('deleteResource 回傳 HTTP 狀態碼', async () => { globalThis.fetch = async (url, opts) => { assert.equal(opts.method, 'DELETE') diff --git a/app/test/logger.test.js b/app/test/logger.test.js new file mode 100644 index 0000000..edd0bcd --- /dev/null +++ b/app/test/logger.test.js @@ -0,0 +1,35 @@ +import { test, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { failError } from '../logger.js' + +const realWrite = process.stderr.write + +afterEach(() => { + process.stderr.write = realWrite +}) + +// 攔截寫入 stderr 的內容 +function captureStderr(fn) { + const chunks = [] + process.stderr.write = (chunk) => { + chunks.push(String(chunk)) + return true + } + try { + fn() + } finally { + process.stderr.write = realWrite + } + return chunks.join('') +} + +test('failError 對 Error 物件輸出名稱、訊息與堆疊', () => { + const out = captureStderr(() => failError(new TypeError('boom'))) + assert.match(out, /\[ERR\] TypeError: boom/) + assert.match(out, /at /) // 堆疊內容 +}) + +test('failError 對非 Error 值輸出其字串形式', () => { + const out = captureStderr(() => failError('plain failure')) + assert.match(out, /\[ERR\] plain failure/) +}) -- 2.53.0 From 2b27da82c036114e23df357c267b44382e0a9080 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:04:29 +0800 Subject: [PATCH 15/43] =?UTF-8?q?docs(release-cleanup):=20README=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20failError=20=E4=B8=A6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=A1=8C=E8=99=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 failError 功能列表/使用範例,更新 validate/gitea-client 連結行號與更新時間。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5d10c87..406fbe6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。 -> 更新時間:2026/06/26 10:54:59 +> 更新時間:2026/06/26 11:03:08 ## 專案列表 @@ -36,15 +36,16 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的 | [success](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L35) | [以 `[OK]` 前綴輸出成功訊息。](#success) | | [warn](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L43) | [以 `[WARN]` 前綴輸出警告訊息。](#warn) | | [fail](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L51) | [以 `[ERR]` 前綴輸出錯誤訊息至 stderr。](#fail) | -| [isEmptyOrNull](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L9) | [判斷值是否為空(空字串、null、undefined 或字串 "null")。](#isemptyornull) | -| [requireValue](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L19) | [要求欄位有值,空值時丟出 Error。](#requirevalue) | -| [requireInteger](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L32) | [要求欄位為非負整數,否則丟出 Error。](#requireinteger) | -| [requireUrl](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L45) | [要求欄位為合法的 http/https URL。](#requireurl) | -| [requireRepository](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L64) | [要求欄位為合法的 owner/repo 形式且無路徑穿越。](#requirerepository) | +| [failError](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L61) | [輸出錯誤至 stderr,Error 物件含名稱與堆疊。](#failerror) | +| [isEmptyOrNull](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L14) | [判斷值是否為空(空字串、null、undefined 或字串 "null")。](#isemptyornull) | +| [requireValue](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L24) | [要求欄位有值,空值時丟出 Error。](#requirevalue) | +| [requireInteger](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L37) | [要求欄位為非負整數,否則丟出 Error。](#requireinteger) | +| [requireUrl](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L50) | [要求欄位為合法的 http/https URL。](#requireurl) | +| [requireRepository](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L69) | [要求欄位為合法的 owner/repo 形式且無路徑穿越。](#requirerepository) | | [loadConfig](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/config.js#L26) | [從環境變數載入並驗證設定,回傳設定物件。](#loadconfig) | -| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L11) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | -| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L34) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | -| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L78) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | +| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L23) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | +| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L47) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | +| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L105) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | | [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L14) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) | | [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L35) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | | [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L17) | [將 tag 分類為保留、刪除或略過。](#categorizetags) | @@ -128,6 +129,22 @@ fail('刪除失敗: v0.9.0 (舊版), HTTP 500') // (stderr) [ERR] 刪除失敗: v0.9.0 (舊版), HTTP 500 ``` + +### failError + +輸出錯誤至 stderr,並依錯誤型別保留可供除錯的上下文:Error 物件輸出「名稱: 訊息」並附上堆疊,其餘輸出其字串形式。集中錯誤輸出格式,供進入點的 `main().catch` 使用。 + +```js +import { failError } from './logger.js' + +failError(new TypeError('boom')) +// (stderr) [ERR] TypeError: boom +// (stderr) <堆疊內容> + +failError('plain failure') +// (stderr) [ERR] plain failure +``` + ### isEmptyOrNull -- 2.53.0 From 8dae4aceafcf986ef021577f581caa77016728b0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:04:29 +0800 Subject: [PATCH 16/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除本輪已修復(JSON/陣列處理、sanitize、failError 收斂、常數抽出)與 已存在測試覆蓋的 finding;保留 7 條設計取捨類(重試/TOCTOU/並行化/SRP)待人工評估。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/findings.json | 155 +++++---------------------------- 1 file changed, 24 insertions(+), 131 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index d4ccfd8..093fe99 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,51 +1,10 @@ [ - { - "level": "critical", - "role": "Maya", - "problem": "cleanupReleases 是執行刪除舊成品的核心函式,但目前缺乏針對 API 呼叫失敗(如 GET 失敗、DELETE 失敗)的測試,無法驗證錯誤處理邏輯是否如預期運作。", - "suggestion": "使用測試框架搭配 mock 伺服器回應,補齊 cleanupReleases 的測試案例,特別是驗證當 deleteResource 回傳非 204 狀態碼時,系統是否正確記錄錯誤並繼續或中斷。", - "location": "app/releases.js:21", - "is_new": false - }, - { - "level": "critical", - "role": "Maya", - "problem": "cleanupOrphanTags 涉及多次 API 交互,目前缺乏測試驗證邏輯,無法確保在 API 請求失敗或標籤分類錯誤時系統的行為一致性。", - "suggestion": "補齊 cleanupOrphanTags 的測試案例,需模擬 fetchAllPages 回傳內容,並驗證對於不同 action(keep/delete/skip)的處理流程是否正確。", - "location": "app/tags.js:35", - "is_new": false - }, - { - "level": "critical", - "role": "Bard", - "location": "app/gitea-client.js:59", - "problem": "在 `fetchAllPages` 中使用 `all.push(...items)` 展開處理分頁資料,若 API 回傳的項目數量龐大(例如數千筆),極可能觸發「超過呼叫堆疊最大長度」(Maximum call stack size exceeded)導致程式崩潰,這對長期運行的工具而言是一大隱憂。", - "suggestion": "建議改用簡單的迴圈 `for (const item of items) { all.push(item); }` 來逐一加入項目,這能完美規避展開運算子在處理大型陣列時的堆疊風險,讓程式運行得更加穩健優雅。", - "is_new": true - }, - { - "level": "critical", - "role": "Mage", - "location": "app/gitea-client.js:55", - "problem": "await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。", - "suggestion": "將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/index.js:46", - "problem": "專案雖然新增了 `app/test/` 資料夾與多個測試檔案,但目前沒看到任何整合測試,且 `app/index.js` 的 `main()` 邏輯未被驗證過,這意味著最核心的清理流程尚未受到測試保護。", - "suggestion": "請在 `app/test/` 中新增整合測試,模擬 `loadConfig` 回傳假設定後,確認 `cleanupReleases` 與 `cleanupOrphanTags` 有被正確呼叫(例如透過 mock GiteaClient),確保流程串接無誤。", - "is_new": true - }, { "level": "warning", "role": "Mage", "location": "app/releases.js:46", - "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 `fail` 並繼續執行,可能導致後續刪除邏輯的不一致。", - "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。", - "is_new": false, + "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除。", + "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff)。", "status": "deferred", "defer_reason": "屬功能性增強與設計取捨。此清理 Action 通常以排程執行,單次失敗可於下次執行補刪;是否引入重試/退避涉及重試次數、間隔與冪等性等設計決策,保留待人工評估。" }, @@ -53,9 +12,8 @@ "level": "warning", "role": "Mage", "location": "app/tags.js:56", - "problem": "在 `cleanupOrphanTags` 中,雖然先重新 fetch 了 release 清單,但 `cleanupReleases` 和 `cleanupOrphanTags` 是非同步執行,且中間無確保一致性的機制。若在 `cleanupReleases` 刪除完成後到 `cleanupOrphanTags` 執行期間,Gitea 上有新的 release 被建立,則 `releaseTagNames` 的快照將會過時,導致正在使用的 tag 被錯誤刪除。", - "suggestion": "考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。", - "is_new": false, + "problem": "`cleanupReleases` 與 `cleanupOrphanTags` 非同步先後執行,若兩者之間 Gitea 上有新的 release 被建立,releaseTagNames 快照會過時,可能導致正在使用的 tag 被錯誤刪除。", + "suggestion": "在兩個 cleanup 步驟之間確保 API 狀態一致性,或在刪除 tag 前再次檢查該 tag 是否仍未被任何現存 release 使用。", "status": "deferred", "defer_reason": "現行程式已在 cleanupOrphanTags 開頭重新抓取最新 release 清單作為主要緩解;殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本的設計取捨,保留待人工評估。" }, @@ -63,110 +21,45 @@ "level": "warning", "role": "Rogue", "location": "app/releases.js:43", - "problem": "在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。", - "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。" + "problem": "刪除舊成品時使用序列化的 `for...of` + `await`,刪除請求逐一排隊等待 API 回應。", + "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化以縮短總執行時間。", + "status": "deferred", + "defer_reason": "刻意保留序列化:可避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序,且與原 bash 版本行為一致。無上限並行化非等價變更,保留待人工評估(可日後改為有上限的並行)。" }, { "level": "warning", "role": "Rogue", "location": "app/tags.js:46", - "problem": "在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。", - "suggestion": "改用 `Promise.all` 將刪除請求並行化,加快清理速度。" - }, - { - "level": "warning", - "role": "Maya", - "problem": "loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。", - "suggestion": "補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。", - "location": "app/config.js:11", - "is_new": false - }, - { - "level": "warning", - "role": "Mage", - "problem": "在 `fetchAllPages` 中未檢查 API 回傳的內容是否符合預期格式(除了 Array 檢查)。若 API 回傳非 JSON 格式的內容(例如 HTML 錯誤頁面),`res.json()` 會拋出 SyntaxError,且未被目前邏輯中的 `try-catch` 明確攔截處理,會導致程式在 catch 區塊中直接結束並輸出錯誤訊息,缺乏更細緻的除錯資訊。", - "suggestion": "在解析 JSON 前,應先判斷 `res.headers.get('content-type')` 是否包含 `application/json`,若非 JSON,應將 `res.text()` 的內容一併在錯誤訊息中輸出,方便排查。", - "location": "app/gitea-client.js:28", - "is_new": false - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/gitea-client.js:48", - "problem": "將 HTTP 錯誤回應內容不加淨化地直接寫入錯誤訊息。攻擊者可能構造特殊的錯誤回應,透過 log 注入或後續錯誤處理機制進行惡意利用。", - "suggestion": "錯誤訊息應限制內容長度(已做到),建議進一步剝離 HTML 標籤,或僅記錄必要的狀態碼與錯誤類型,避免直接紀錄原始回應內容。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/gitea-client.js:67", - "problem": "將 Content-Type 不符的錯誤內容直接寫入錯誤訊息。同樣面臨 log 注入或惡意回應內容的問題。", - "suggestion": "同上,建議精簡錯誤資訊,移除可能包含惡意 payload 的回應 body 部分。", - "is_new": true + "problem": "刪除孤立 tag 時同樣使用序列化迴圈,造成總執行時間拉長。", + "suggestion": "改用 `Promise.all` 將刪除請求並行化。", + "status": "deferred", + "defer_reason": "與 app/releases.js:43 同理,刻意保留序列化以避免併發壓力與速率限制並維持記錄順序,屬設計取捨,保留待人工評估。" }, { "level": "warning", "role": "Leo", "location": "app/releases.js:37", - "problem": "這裡的 `cleanupReleases` 函式同時負責了「取得資料」、「判斷邏輯」與「執行副作用(刪除)」三種責任,這違反了單一職責原則(SRP),隨著 API 呼叫變複雜,這會導致未來很難單獨測試刪除邏輯。", - "suggestion": "建議將 `cleanupReleases` 拆分為「取得成品與過濾需要刪除的成品」的純邏輯層,以及「執行刪除請求」的執行層。目前的 `selectReleasesToDelete` 已經做了一部分,建議把迴圈刪除的部分也封裝成一個負責執行動作的函式。", - "is_new": true + "problem": "`cleanupReleases` 同時負責取得資料、判斷邏輯與執行刪除副作用,違反單一職責原則,未來不易單獨測試刪除邏輯。", + "suggestion": "將取得/過濾的純邏輯與執行刪除的副作用層拆開(目前 `selectReleasesToDelete` 已做了一部分)。", + "status": "deferred", + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑測試覆蓋;進一步拆出刪除迴圈為設計偏好,效益有限且增加表面積,保留待人工評估。" }, { "level": "warning", "role": "Leo", "location": "app/tags.js:46", - "problem": "在 `cleanupOrphanTags` 中,直接在主流程中遍歷並執行刪除,同樣面臨未來如果需要對刪除失敗進行更複雜的處理(例如重試、批次處理)時,邏輯會變得難以維護。", - "suggestion": "建議將 `cleanupOrphanTags` 參考 `cleanupReleases` 的結構,將「分類與過濾」與「執行刪除動作」進一步解耦,並對每個 tag 的刪除動作進行更細緻的錯誤處理。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/gitea-client.js:54", - "problem": "fetchAllPages 的迴圈終止條件僅檢查 `!Array.isArray(items) || items.length === 0`。若 API 因為某種狀況(如認證過期或 API 內部錯誤)回傳了非陣列格式的 JSON 錯誤訊息,會直接視為「最後一頁」而提前終止迴圈,導致回傳不完整的清單且未報錯。", - "suggestion": "應在確認 res.ok 為 true 的前提下,嚴格要求 items 為陣列。若 res.ok 為 true 但 items 不為陣列,應拋出錯誤而非將其視為終止訊號。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/gitea-client.js:33", - "problem": "`fetchAllPages` 使用了 `AbortSignal.timeout(REQUEST_TIMEOUT_MS)`,但在測試 `app/test/gitea-client.js` 時,並未測試過「網路逾時」情境。", - "suggestion": "在 `app/test/gitea-client.js` 中增加一個測試案例,模擬 fetch 函數直接拋出 `AbortError`,驗證 `fetchAllPages` 能否正確處理該錯誤,而不是讓容器無預警崩潰。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/config.js:36", - "problem": "參數 `GITEA_TOKEN` 若提供,在 `loadConfig` 中只會檢查其是否為空,且為了資安會遮蔽輸出。這很棒,但缺乏對 `GITEA_TOKEN` 格式或長度的邊界測試。", - "suggestion": "在 `app/test/config.test.js` 中增加測試案例,針對 `GITEA_TOKEN` 為極短字串(如空字串、單一字元)或極長字串進行測試,確認系統行為符合預期。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/index.js:34", - "problem": "`reportFatal` 直接操作 `process.stderr` 並在 `main().catch` 中手動調用,這與 `logger.js` 中定義的 `fail` 函式職責重疊,這會在未來增加日誌格式變更的維護成本。", - "suggestion": "建議直接呼叫 `fail` 函式,並在 `logger.js` 中根據錯誤類型(是否為 Error 物件)自動處理堆疊追蹤資訊,將「如何輸出錯誤」的邏輯全部收斂至 `logger.js`。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/validate.js:47", - "problem": "雖然驗證邏輯完整,但 `requireUrl` 和 `requireRepository` 的規則直接寫死在函式內。如果未來有其他的 API 端點需要不同的驗證規則,會產生大量重複程式碼。", - "suggestion": "考慮將驗證規則(Regex 或協定清單)提取為設定物件或共用常數,這能讓維護者一眼看出系統允許的 URL 限制,方便未來擴充。", - "is_new": true + "problem": "`cleanupOrphanTags` 直接在主流程遍歷並刪除,未來若需更複雜的失敗處理(重試、批次)會難以維護。", + "suggestion": "參考 cleanupReleases 結構,將分類過濾與執行刪除進一步解耦。", + "status": "deferred", + "defer_reason": "分類邏輯(categorizeTags)已抽為純函式並具測試;是否進一步拆出刪除執行層為設計偏好,與 app/releases.js:37 一併保留待人工評估。" }, { "level": "info", "role": "Rogue", "location": "app/gitea-client.js:33", - "problem": "`fetchAllPages` 採取線性逐頁請求,資料量龐大時會導致總請求時間過長。", - "suggestion": "若 API 支援並行讀取,建議先取得總頁數並行發出請求,而非序列式逐頁讀取。", - "is_new": true + "problem": "`fetchAllPages` 採線性逐頁請求,資料量龐大時總請求時間較長。", + "suggestion": "若 API 支援,先取得總頁數再並行發出請求。", + "status": "deferred", + "defer_reason": "Gitea 分頁未可靠提供總頁數,需額外解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時已足夠穩健且簡單,並行化屬最佳化取捨,保留待人工評估。" } ] -- 2.53.0 From 49ac5d4ec43e78fe0b8a1cecaf8e363597b1ea87 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:05:47 +0000 Subject: [PATCH 17/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 116 ++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 44 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 093fe99..35c67fa 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,65 +1,93 @@ [ + { + "level": "critical", + "role": "Bard", + "location": "app/gitea-client.js:59", + "problem": "fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。", + "suggestion": "改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。" + }, + { + "level": "critical", + "role": "Maya", + "location": "app/index.js:46", + "problem": "缺乏核心清理流程的整合測試,且 cleanup 步驟之間的 API 狀態一致性未受保障,可能導致正在使用的 tag 被錯誤刪除。", + "suggestion": "於 app/test/ 新增整合測試(mock GiteaClient),驗證 cleanup 流程串接,並在兩個 cleanup 步驟之間確保 API 狀態一致,或於刪除 tag 前再次確認其未被現存 release 使用。" + }, + { + "level": "critical", + "role": "Mage", + "location": "app/releases.js:38", + "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,導致容器無法確保後續清理的一致性與部分成功重試。", + "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略,確保清理任務具備健壯性。" + }, { "level": "warning", "role": "Mage", "location": "app/releases.js:46", - "problem": "在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除。", - "suggestion": "對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff)。", - "status": "deferred", - "defer_reason": "屬功能性增強與設計取捨。此清理 Action 通常以排程執行,單次失敗可於下次執行補刪;是否引入重試/退避涉及重試次數、間隔與冪等性等設計決策,保留待人工評估。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/tags.js:56", - "problem": "`cleanupReleases` 與 `cleanupOrphanTags` 非同步先後執行,若兩者之間 Gitea 上有新的 release 被建立,releaseTagNames 快照會過時,可能導致正在使用的 tag 被錯誤刪除。", - "suggestion": "在兩個 cleanup 步驟之間確保 API 狀態一致性,或在刪除 tag 前再次檢查該 tag 是否仍未被任何現存 release 使用。", - "status": "deferred", - "defer_reason": "現行程式已在 cleanupOrphanTags 開頭重新抓取最新 release 清單作為主要緩解;殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本的設計取捨,保留待人工評估。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/releases.js:43", - "problem": "刪除舊成品時使用序列化的 `for...of` + `await`,刪除請求逐一排隊等待 API 回應。", - "suggestion": "改用 `Promise.all` 搭配 `map` 將刪除請求並行化以縮短總執行時間。", - "status": "deferred", - "defer_reason": "刻意保留序列化:可避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序,且與原 bash 版本行為一致。無上限並行化非等價變更,保留待人工評估(可日後改為有上限的並行)。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/tags.js:46", - "problem": "刪除孤立 tag 時同樣使用序列化迴圈,造成總執行時間拉長。", - "suggestion": "改用 `Promise.all` 將刪除請求並行化。", - "status": "deferred", - "defer_reason": "與 app/releases.js:43 同理,刻意保留序列化以避免併發壓力與速率限制並維持記錄順序,屬設計取捨,保留待人工評估。" + "problem": "刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼 (502, 503, 504) 實作重試機制,且在遇到失敗時未停止後續請求,導致大量無意義錯誤。", + "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試機制,並引入錯誤閾值,當失敗次數過高時立即中斷流程。" }, { "level": "warning", "role": "Leo", "location": "app/releases.js:37", - "problem": "`cleanupReleases` 同時負責取得資料、判斷邏輯與執行刪除副作用,違反單一職責原則,未來不易單獨測試刪除邏輯。", - "suggestion": "將取得/過濾的純邏輯與執行刪除的副作用層拆開(目前 `selectReleasesToDelete` 已做了一部分)。", - "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑測試覆蓋;進一步拆出刪除迴圈為設計偏好,效益有限且增加表面積,保留待人工評估。" + "problem": "cleanupReleases 違反單一職責原則,同時處理資料獲取、邏輯判斷與副作用執行,不易測試。", + "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式,以利單獨測試。" }, { "level": "warning", + "role": "Rogue", + "location": "app/releases.js:43", + "problem": "清理成品與刪除 tag 使用序列化迴圈,導致 API 請求逐一排隊,整體執行時間拉長。", + "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化以縮短執行時間。" + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/releases.js:56", + "problem": "直接將 API 回傳的 id 與 tag 名稱拼接到 URL 中進行 DELETE 操作,未經驗證,存在路徑穿越或 SSRF 風險。", + "suggestion": "在使用 id 或 tag 名稱構建 URL 前,必須嚴格驗證其字元組成(如僅允許特定格式或編碼處理)。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/config.js:36", + "problem": "缺乏對 GITEA_TOKEN 長度或格式的邊界測試,以及對 KEEP_COUNT 格式異常的檢查。", + "suggestion": "在測試檔中增加針對 Token 與 KEEP_COUNT 的邊界測試,並在 loadConfig 內加強格式轉換檢查。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/gitea-client.js:33", + "problem": "fetchAllPages 使用了逾時訊號,但測試套件未驗證網路逾時情境,且總耗時未受限制。", + "suggestion": "在測試中模擬 AbortError,並考慮對整個 fetchAllPages 流程引入總執行時間限制。" + }, + { + "level": "warning", + "role": "Bard", + "location": "app/logger.js:5", + "problem": "分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。", + "suggestion": "將分隔線管理集中化,並考慮提供動態產生方法。" + }, + { + "level": "info", "role": "Leo", - "location": "app/tags.js:46", - "problem": "`cleanupOrphanTags` 直接在主流程遍歷並刪除,未來若需更複雜的失敗處理(重試、批次)會難以維護。", - "suggestion": "參考 cleanupReleases 結構,將分類過濾與執行刪除進一步解耦。", - "status": "deferred", - "defer_reason": "分類邏輯(categorizeTags)已抽為純函式並具測試;是否進一步拆出刪除執行層為設計偏好,與 app/releases.js:37 一併保留待人工評估。" + "location": "app/validate.js:47", + "problem": "驗證規則寫死在函式內,易產生重複程式碼,且缺乏擴充性。", + "suggestion": "將驗證規則提取為設定物件或共用常數,或考慮使用 Zod 等 Schema 套件進行驗證與型別轉換。" }, { "level": "info", "role": "Rogue", "location": "app/gitea-client.js:33", - "problem": "`fetchAllPages` 採線性逐頁請求,資料量龐大時總請求時間較長。", - "suggestion": "若 API 支援,先取得總頁數再並行發出請求。", - "status": "deferred", - "defer_reason": "Gitea 分頁未可靠提供總頁數,需額外解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時已足夠穩健且簡單,並行化屬最佳化取捨,保留待人工評估。" + "problem": "fetchAllPages 採線性逐頁請求,資料龐大時效能不佳,且回應處理透過 .text() 再轉 JSON 造成重複記憶體開銷。", + "suggestion": "若 API 支援,先取得總頁數後並行請求,並直接處理 Response 的 ReadableStream 以提升效能。" + }, + { + "level": "info", + "role": "Leo", + "location": "app/index.js:34", + "problem": "錯誤報告邏輯與 logger.js 重疊,職責不清晰。", + "suggestion": "統一透過 logger.js 的 fail 函式處理錯誤輸出,將邏輯收斂。" } ] -- 2.53.0 From aa4f6364e2c569ad1d2a882788d48a2d82ff77d5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:21:56 +0800 Subject: [PATCH 18/43] =?UTF-8?q?fix(release-cleanup):=20=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E5=88=86=E9=A0=81=E4=B8=8A=E9=99=90=E4=B8=A6=E7=B7=A8?= =?UTF-8?q?=E7=A2=BC=E5=88=AA=E9=99=A4=20URL=20=E8=B7=AF=E5=BE=91=E7=89=87?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchAllPages 加入 MAX_PAGES(1000)安全斷點,避免 API 異常時無限迴圈 - 刪除 release/tag 時對 id 與 tag 名稱做 encodeURIComponent, 防止特殊字元造成路徑穿越(正常數值/版本字串編碼後不變) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/gitea-client.js | 7 +++++++ app/releases.js | 3 ++- app/tags.js | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/gitea-client.js b/app/gitea-client.js index f54fdd8..7a769ef 100644 --- a/app/gitea-client.js +++ b/app/gitea-client.js @@ -4,6 +4,9 @@ // 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。 const REQUEST_TIMEOUT_MS = 30000 +// 分頁讀取的最大頁數上限,作為安全斷點:即使 API 異常未以空陣列結尾,也不致無限迴圈耗盡資源。 +const MAX_PAGES = 1000 + /** * 將回應內容整理成可安全寫入錯誤訊息的片段:移除控制字元(避免換行等造成的 log 注入)並限制長度。 * @param {string} text 原始回應文字 @@ -49,6 +52,10 @@ export class GiteaClient { let page = 1 while (true) { + if (page > MAX_PAGES) { + throw new Error(`GET ${baseUrl} exceeded MAX_PAGES (${MAX_PAGES}); aborting to avoid an unbounded loop`) + } + const url = `${baseUrl}?page=${page}` const res = await fetch(url, { headers: this.headers, diff --git a/app/releases.js b/app/releases.js index 4bd40f3..731add8 100644 --- a/app/releases.js +++ b/app/releases.js @@ -56,7 +56,8 @@ export async function cleanupReleases(client, config) { continue } - const url = `${config.releaseApiUrl}/${id}` + // 對 id 做編碼,避免非預期內容被拼接進 URL(防路徑穿越);正常數值 id 編碼後不變。 + const url = `${config.releaseApiUrl}/${encodeURIComponent(id)}` info(`DELETE ${tag} (${name})`) const code = await client.deleteResource(url) diff --git a/app/tags.js b/app/tags.js index c991da3..22becd9 100644 --- a/app/tags.js +++ b/app/tags.js @@ -61,7 +61,8 @@ export async function cleanupOrphanTags(client, config) { continue } - const url = `${config.tagApiUrl}/${tag.name}` + // 對 tag 名稱做編碼,避免名稱中的特殊字元被拼接進 URL(防路徑穿越);一般 tag 名編碼後不變。 + const url = `${config.tagApiUrl}/${encodeURIComponent(tag.name)}` info(`DELETE tag ${tag.name}`) const code = await client.deleteResource(url) -- 2.53.0 From 0ba93a40a9bc980f2b366a2e67f72d105e7972d8 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:21:56 +0800 Subject: [PATCH 19/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=20MAX=5FPAGES=20=E8=88=87=E5=88=AA=E9=99=A4=20URL=20?= =?UTF-8?q?=E7=B7=A8=E7=A2=BC=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 fetchAllPages 超過 MAX_PAGES 中止的測試,以及 cleanupReleases/ cleanupOrphanTags 對含 ../ 的 id/tag 名稱編碼的測試。測試共 50 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/gitea-client.test.js | 10 ++++++++++ app/test/releases.test.js | 10 ++++++++++ app/test/tags.test.js | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js index 423ad4b..6b701a6 100644 --- a/app/test/gitea-client.test.js +++ b/app/test/gitea-client.test.js @@ -112,6 +112,16 @@ test('fetchAllPages 在 fetch 因逾時拋出 AbortError 時向外拋出', async ) }) +test('fetchAllPages 在頁數超過 MAX_PAGES 時中止以避免無限迴圈', async () => { + // 永遠回傳非空陣列,模擬 API 不以空陣列結尾的異常情形 + globalThis.fetch = async () => jsonResponse([{ id: 1 }]) + const client = new GiteaClient({}) + await assert.rejects( + () => client.fetchAllPages('https://example.com/api'), + /exceeded MAX_PAGES/, + ) +}) + test('deleteResource 回傳 HTTP 狀態碼', async () => { globalThis.fetch = async (url, opts) => { assert.equal(opts.method, 'DELETE') diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 6c88821..0f3e53b 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -93,6 +93,16 @@ test('cleanupReleases 在 deleteResource 回傳非 204 時仍繼續處理其餘 assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1']) }) +test('cleanupReleases 對 id 進行 URL 編碼(防路徑穿越)', async () => { + const client = fakeClient([ + { id: 1, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, + { id: '../evil', tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + ]) + await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) + // 保留最新(id=1),刪除較舊者(惡意 id);id 中的 ../ 應被編碼 + assert.deepEqual(client.deleted, ['http://x/releases/..%2Fevil']) +}) + test('cleanupReleases 略過沒有 id 的 release', async () => { const client = fakeClient([ { id: 1, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, diff --git a/app/test/tags.test.js b/app/test/tags.test.js index debbc1f..93c1988 100644 --- a/app/test/tags.test.js +++ b/app/test/tags.test.js @@ -86,3 +86,13 @@ test('cleanupOrphanTags 在沒有任何 release 時刪除所有具名 tag', asyn await cleanupOrphanTags(client, config) assert.deepEqual(client.deleted, ['http://x/tags/v2.0.0', 'http://x/tags/v1.0.0']) }) + +test('cleanupOrphanTags 對含特殊字元的 tag 名稱進行 URL 編碼(防路徑穿越)', async () => { + const client = fakeClient({ + releases: [], + tagList: [{ name: '../evil' }], + }) + await cleanupOrphanTags(client, config) + // 名稱中的 ../ 應被編碼,不會形成可穿越的路徑 + assert.deepEqual(client.deleted, ['http://x/tags/..%2Fevil']) +}) -- 2.53.0 From 322e42087d70fd2384e0179032c067a7434f0f13 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:21:56 +0800 Subject: [PATCH 20/43] =?UTF-8?q?docs(release-cleanup):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README=20gitea-client=20=E9=80=A3=E7=B5=90=E8=A1=8C?= =?UTF-8?q?=E8=99=9F=E8=88=87=E6=9B=B4=E6=96=B0=E6=99=82=E9=96=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 406fbe6..fe9773a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。 -> 更新時間:2026/06/26 11:03:08 +> 更新時間:2026/06/26 11:20:50 ## 專案列表 @@ -43,9 +43,9 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的 | [requireUrl](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L50) | [要求欄位為合法的 http/https URL。](#requireurl) | | [requireRepository](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L69) | [要求欄位為合法的 owner/repo 形式且無路徑穿越。](#requirerepository) | | [loadConfig](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/config.js#L26) | [從環境變數載入並驗證設定,回傳設定物件。](#loadconfig) | -| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L23) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | -| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L47) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | -| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L105) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | +| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L26) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) | +| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L50) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | +| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L112) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | | [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L14) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) | | [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L35) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | | [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L17) | [將 tag 分類為保留、刪除或略過。](#categorizetags) | -- 2.53.0 From c11440e139380dd15a32cbfa6d9259f303135443 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:21:56 +0800 Subject: [PATCH 21/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除本輪已修復(MAX_PAGES、URL 編碼)與已具測試/已收斂的 finding; 保留 5 條設計取捨類(重試/錯誤閾值/SRP/並行化)待人工評估; 將 logger 分隔線常數建議登記為不適用於 exclusions。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 6 ++ .gitea/ai-review/findings.json | 96 +++++++++----------------------- 2 files changed, 31 insertions(+), 71 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 327dc5e..2cc3ae6 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -16,5 +16,11 @@ "role": "Bard", "original_finding": "在括号前面增加一个空格:`# 複製 Node.js 應用程式 (不含測試)`", "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "app/logger.js:5", + "role": "Bard", + "original_finding": "分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。建議將分隔線管理集中化,並考慮提供動態產生方法。", + "reason": "不適用。分隔線已是模組頂層的具名常數(LINE/SUBLINE),本即集中管理且為通用良好實務;固定寬度分隔線改為動態產生方法只會徒增複雜度,無實質效益,故不採納。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 35c67fa..fb00b6d 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,93 +1,47 @@ [ - { - "level": "critical", - "role": "Bard", - "location": "app/gitea-client.js:59", - "problem": "fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。", - "suggestion": "改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。" - }, - { - "level": "critical", - "role": "Maya", - "location": "app/index.js:46", - "problem": "缺乏核心清理流程的整合測試,且 cleanup 步驟之間的 API 狀態一致性未受保障,可能導致正在使用的 tag 被錯誤刪除。", - "suggestion": "於 app/test/ 新增整合測試(mock GiteaClient),驗證 cleanup 流程串接,並在兩個 cleanup 步驟之間確保 API 狀態一致,或於刪除 tag 前再次確認其未被現存 release 使用。" - }, - { - "level": "critical", - "role": "Mage", - "location": "app/releases.js:38", - "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,導致容器無法確保後續清理的一致性與部分成功重試。", - "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略,確保清理任務具備健壯性。" - }, { "level": "warning", "role": "Mage", "location": "app/releases.js:46", - "problem": "刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼 (502, 503, 504) 實作重試機制,且在遇到失敗時未停止後續請求,導致大量無意義錯誤。", - "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試機制,並引入錯誤閾值,當失敗次數過高時立即中斷流程。" + "problem": "刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。", + "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試,並引入錯誤閾值,失敗次數過高時中斷流程。", + "status": "deferred", + "defer_reason": "屬功能性增強與設計取捨。此清理 Action 以排程執行,單次失敗可於下次補刪;重試/退避/錯誤閾值涉及次數、間隔與冪等性等決策,保留待人工評估。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:38", + "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", + "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", + "status": "deferred", + "defer_reason": "與 app/releases.js:46 同屬錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續,讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" }, { "level": "warning", "role": "Leo", "location": "app/releases.js:37", - "problem": "cleanupReleases 違反單一職責原則,同時處理資料獲取、邏輯判斷與副作用執行,不易測試。", - "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式,以利單獨測試。" + "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則,不易單獨測試刪除邏輯。", + "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", + "status": "deferred", + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑與編碼測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" }, { "level": "warning", "role": "Rogue", "location": "app/releases.js:43", - "problem": "清理成品與刪除 tag 使用序列化迴圈,導致 API 請求逐一排隊,整體執行時間拉長。", - "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化以縮短執行時間。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/releases.js:56", - "problem": "直接將 API 回傳的 id 與 tag 名稱拼接到 URL 中進行 DELETE 操作,未經驗證,存在路徑穿越或 SSRF 風險。", - "suggestion": "在使用 id 或 tag 名稱構建 URL 前,必須嚴格驗證其字元組成(如僅允許特定格式或編碼處理)。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/config.js:36", - "problem": "缺乏對 GITEA_TOKEN 長度或格式的邊界測試,以及對 KEEP_COUNT 格式異常的檢查。", - "suggestion": "在測試檔中增加針對 Token 與 KEEP_COUNT 的邊界測試,並在 loadConfig 內加強格式轉換檢查。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/gitea-client.js:33", - "problem": "fetchAllPages 使用了逾時訊號,但測試套件未驗證網路逾時情境,且總耗時未受限制。", - "suggestion": "在測試中模擬 AbortError,並考慮對整個 fetchAllPages 流程引入總執行時間限制。" - }, - { - "level": "warning", - "role": "Bard", - "location": "app/logger.js:5", - "problem": "分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。", - "suggestion": "將分隔線管理集中化,並考慮提供動態產生方法。" - }, - { - "level": "info", - "role": "Leo", - "location": "app/validate.js:47", - "problem": "驗證規則寫死在函式內,易產生重複程式碼,且缺乏擴充性。", - "suggestion": "將驗證規則提取為設定物件或共用常數,或考慮使用 Zod 等 Schema 套件進行驗證與型別轉換。" + "problem": "清理成品與刪除 tag 使用序列化迴圈,API 請求逐一排隊,整體執行時間拉長。", + "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化。", + "status": "deferred", + "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。" }, { "level": "info", "role": "Rogue", "location": "app/gitea-client.js:33", - "problem": "fetchAllPages 採線性逐頁請求,資料龐大時效能不佳,且回應處理透過 .text() 再轉 JSON 造成重複記憶體開銷。", - "suggestion": "若 API 支援,先取得總頁數後並行請求,並直接處理 Response 的 ReadableStream 以提升效能。" - }, - { - "level": "info", - "role": "Leo", - "location": "app/index.js:34", - "problem": "錯誤報告邏輯與 logger.js 重疊,職責不清晰。", - "suggestion": "統一透過 logger.js 的 fail 函式處理錯誤輸出,將邏輯收斂。" + "problem": "fetchAllPages 採線性逐頁請求,資料龐大時效能不佳。", + "suggestion": "若 API 支援,先取得總頁數後並行請求。", + "status": "deferred", + "defer_reason": "Gitea 分頁未可靠提供總頁數,需解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時與 MAX_PAGES 上限已足夠穩健,並行化屬最佳化取捨,保留待人工評估。" } ] -- 2.53.0 From c0285794ae4287224c09ed95fe72b0e60d3e96fc Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:22:50 +0000 Subject: [PATCH 22/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 120 ++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fb00b6d..6306dff 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,30 +1,40 @@ [ { - "level": "warning", + "level": "critical", "role": "Mage", - "location": "app/releases.js:46", - "problem": "刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。", - "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試,並引入錯誤閾值,失敗次數過高時中斷流程。", - "status": "deferred", - "defer_reason": "屬功能性增強與設計取捨。此清理 Action 以排程執行,單次失敗可於下次補刪;重試/退避/錯誤閾值涉及次數、間隔與冪等性等決策,保留待人工評估。" + "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,導致容器無法確保後續清理的一致性與部分成功重試。", + "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略,確保清理任務具備健壯性。", + "location": "app/releases.js:38", + "is_new": false + }, + { + "level": "critical", + "role": "Mage", + "location": "app/gitea-client.js:63", + "problem": "在 fetchAllPages 的 while 迴圈中使用了 AbortSignal.timeout。若運行環境的 Node.js 版本低於 16,此程式碼會直接崩潰。", + "suggestion": "確認運行環境的 Node.js 版本,若未來需向下相容,建議使用 AbortController 搭配 setTimeout 手動實作逾時控制。" + }, + { + "level": "critical", + "role": "Maya", + "location": "app/index.js:31", + "problem": "main 函式執行失敗時會觸發 process.exit(1),確保 CI/CD 流程能正確偵測錯誤。目前的測試僅驗證了 Promise 被 reject,但並未驗證程式是否真的正確以非零狀態碼結束。", + "suggestion": "請在 app/test/main.test.js 中,模擬 main 拋出錯誤的情境,並透過 mock process.exit 來驗證當 main 執行失敗時,程式碼確實執行了 process.exit(1)。", + "is_new": true }, { "level": "warning", "role": "Mage", - "location": "app/releases.js:38", - "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", - "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", - "status": "deferred", - "defer_reason": "與 app/releases.js:46 同屬錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續,讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" + "location": "app/releases.js:46", + "problem": "刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。若 release 清單非常龐大,會佔用大量記憶體。", + "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試,並引入錯誤閾值。考慮在 fetchAllPages 中加入串流處理(Stream)或實作分批讀取機制,避免將所有資料一次性載入記憶體。" }, { "level": "warning", "role": "Leo", "location": "app/releases.js:37", - "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則,不易單獨測試刪除邏輯。", - "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", - "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑與編碼測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" + "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則,且未對 release 物件結構進行進一步驗證,可能導致資料正確性風險。", + "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。建議增加對於 release 物件結構的進一步驗證,並強化檢查邏輯。" }, { "level": "warning", @@ -33,7 +43,68 @@ "problem": "清理成品與刪除 tag 使用序列化迴圈,API 請求逐一排隊,整體執行時間拉長。", "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化。", "status": "deferred", - "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。" + "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。", + "is_new": false + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/gitea-client.js:101", + "problem": "在處理分頁資料時,使用 for...of 逐一執行 all.push(item),導致大量記憶體配置與無謂的物件拷貝,效率不佳。", + "suggestion": "建議直接將 items 整批解構並存入 all,或者使用 Array.prototype.push.apply(all, items) 來減少迴圈開銷。" + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/gitea-client.js:66", + "problem": "攻擊者可透過惡意伺服器回傳極大的錯誤回應內容,利用此處未限制大小的 res.text() 直接讀取整個回應主體,導致記憶體耗盡 (DoS)。", + "suggestion": "應限制讀取的回應大小,例如在請求前檢查 Content-Length 或使用串流處理並設定讀取限制。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/gitea-client.js:77", + "problem": "攻擊者可透過惡意伺服器回傳極大的 JSON 資料,利用此處未限制大小的 res.json() 直接解析整個回應,導致記憶體耗盡 (DoS)。", + "suggestion": "應對 API 回應設定明確的大小上限,超過時拒絕解析並拋出異常。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/validate.js:14", + "problem": "isEmptyOrNull 判斷式包含 value === 'null',導致正確字串內容 'null' 被判定為空,破壞語義一致性。", + "suggestion": "建議移除對 'null' 字串的特殊判定,改由呼叫端明確處理邏輯。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/gitea-client.js:13", + "problem": "sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。", + "suggestion": "請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/logger.js:9", + "problem": "logger.js 中的輸出函式(如 separator, section, info, success, warn, fail)負責 Action 的核心視覺輸出格式,但目前缺乏測試驗證其實際輸出內容是否正確對齊並符合格式要求。", + "suggestion": "請在 app/test/logger.test.js 中補齊對這些函式的測試,驗證其是否正確寫入預期的格式內容(含分隔線與正確的前綴)到 stdout。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/releases.js:17", + "problem": "selectReleasesToDelete 針對每一筆 release 重複執行 Date.parse,浪費 CPU 週期。", + "suggestion": "應在排序前先執行一次 map 轉換,預先計算時間戳記。" + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/tags.js:46", + "problem": "cleanupOrphanTags 在迴圈中重複掃描所有 release 名稱陣列,時間複雜度為 O(tags * releases),效能低落。", + "suggestion": "應在進入 tag 迴圈前,先將所有 release 的 tag 名稱轉成一個 Set 物件,將查找複雜度降為 O(1)。" }, { "level": "info", @@ -42,6 +113,21 @@ "problem": "fetchAllPages 採線性逐頁請求,資料龐大時效能不佳。", "suggestion": "若 API 支援,先取得總頁數後並行請求。", "status": "deferred", - "defer_reason": "Gitea 分頁未可靠提供總頁數,需解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時與 MAX_PAGES 上限已足夠穩健,並行化屬最佳化取捨,保留待人工評估。" + "defer_reason": "Gitea 分頁未可靠提供總頁數,需解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時與 MAX_PAGES 上限已足夠穩健,並行化屬最佳化取捨,保留待人工評估。", + "is_new": false + }, + { + "level": "info", + "role": "Leo", + "location": "app/config.js:33", + "problem": "直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用。", + "suggestion": "建議在複雜系統中,將環境變數讀取抽離為獨立的 Provider 或 ConfigFactory。" + }, + { + "level": "info", + "role": "Leo", + "location": "app/tags.js:21", + "problem": "categorizeTags 內部頻繁轉換 Set,若此函式被頻繁呼叫,可能造成效能浪費。", + "suggestion": "建議調整設計,讓 categorizeTags 接收已經轉好的 Set,將轉換開銷移至上層。" } ] -- 2.53.0 From 87db9030d6dcdec3e6715725b904b0d9a7d9bc3e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:29:30 +0800 Subject: [PATCH 23/43] =?UTF-8?q?perf(release-cleanup):=20selectReleasesTo?= =?UTF-8?q?Delete=20=E9=A0=90=E5=85=88=E8=A8=88=E7=AE=97=E6=99=82=E9=96=93?= =?UTF-8?q?=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排序前先一次性計算各 release 的 created_at 時間戳,避免在排序比較器中 重複呼叫 Date.parse;排序結果與原行為等價。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/releases.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/releases.js b/app/releases.js index 731add8..c975fe5 100644 --- a/app/releases.js +++ b/app/releases.js @@ -17,8 +17,12 @@ export function selectReleasesToDelete(releases, keepCount) { const time = Date.parse(release?.created_at) return Number.isNaN(time) ? 0 : time } - const sorted = [...releases].sort((a, b) => createdTime(b) - createdTime(a)) - return sorted.slice(keepCount) + // 先一次性計算每筆的時間戳,避免在排序比較中重複呼叫 Date.parse。 + return releases + .map((release) => ({ release, time: createdTime(release) })) + .sort((a, b) => b.time - a.time) + .map((entry) => entry.release) + .slice(keepCount) } /** -- 2.53.0 From 826b5590060d79fd40e528a76a0daf20964a5e20 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:29:30 +0800 Subject: [PATCH 24/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=E7=B5=90=E6=9D=9F=E7=A2=BC=E3=80=81sanitizeBody=20?= =?UTF-8?q?=E8=88=87=20logger=20=E8=BC=B8=E5=87=BA=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增子行程驗證 index.js 失敗時以非零狀態結束、fetchAllPages 錯誤訊息 清理控制字元與長度限制、以及 logger 各輸出函式格式與 stdout/stderr 分流測試。 測試共 56 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/gitea-client.test.js | 20 +++++++++++++ app/test/logger.test.js | 56 ++++++++++++++++++++++++++++++++++- app/test/main.test.js | 13 ++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js index 6b701a6..deb6c01 100644 --- a/app/test/gitea-client.test.js +++ b/app/test/gitea-client.test.js @@ -122,6 +122,26 @@ test('fetchAllPages 在頁數超過 MAX_PAGES 時中止以避免無限迴圈', a ) }) +test('fetchAllPages 的錯誤訊息會清理控制字元並限制長度(sanitizeBody)', async () => { + const nasty = 'line1\nline2\r\tinjected' + 'x'.repeat(500) + globalThis.fetch = async () => ({ + ok: false, + status: 500, + text: async () => nasty, + }) + const client = new GiteaClient({}) + let message = '' + try { + await client.fetchAllPages('https://example.com/api') + } catch (error) { + message = error.message + } + // 控制字元(換行、Tab 等)已被移除 + assert.doesNotMatch(message, /[\u0000-\u001F]/) + // 回應內容片段受 200 字元上限約束(原始有 500 個 x) + assert.ok((message.match(/x/g) || []).length <= 200) +}) + test('deleteResource 回傳 HTTP 狀態碼', async () => { globalThis.fetch = async (url, opts) => { assert.equal(opts.method, 'DELETE') diff --git a/app/test/logger.test.js b/app/test/logger.test.js index edd0bcd..3fdc373 100644 --- a/app/test/logger.test.js +++ b/app/test/logger.test.js @@ -1,11 +1,65 @@ import { test, afterEach } from 'node:test' import assert from 'node:assert/strict' -import { failError } from '../logger.js' +import { + separator, + section, + info, + success, + warn, + fail, + failError, +} from '../logger.js' const realWrite = process.stderr.write +const realStdoutWrite = process.stdout.write afterEach(() => { process.stderr.write = realWrite + process.stdout.write = realStdoutWrite +}) + +// 攔截寫入 stdout 的內容 +function captureStdout(fn) { + const chunks = [] + process.stdout.write = (chunk) => { + chunks.push(String(chunk)) + return true + } + try { + fn() + } finally { + process.stdout.write = realStdoutWrite + } + return chunks.join('') +} + +test('info/success/warn 以正確前綴寫入 stdout', () => { + assert.equal(captureStdout(() => info('hi')), '[INFO] hi\n') + assert.equal(captureStdout(() => success('ok')), '[OK] ok\n') + assert.equal(captureStdout(() => warn('careful')), '[WARN] careful\n') +}) + +test('separator 輸出等號分隔線', () => { + const out = captureStdout(() => separator()) + assert.match(out, /^\n={50}\n$/) +}) + +test('section 輸出分隔線、標題與虛線', () => { + const out = captureStdout(() => section('參數檢查')) + assert.match(out, /={50}\n參數檢查\n-{50}\n/) +}) + +test('fail 寫入 stderr 而非 stdout', () => { + const stdout = captureStdout(() => { + const chunks = [] + process.stderr.write = (chunk) => { + chunks.push(String(chunk)) + return true + } + fail('boom') + assert.equal(chunks.join(''), '[ERR] boom\n') + }) + assert.equal(stdout, '') // fail 不應寫入 stdout }) // 攔截寫入 stderr 的內容 diff --git a/app/test/main.test.js b/app/test/main.test.js index 00e8d74..e7286ab 100644 --- a/app/test/main.test.js +++ b/app/test/main.test.js @@ -1,5 +1,7 @@ import { test, afterEach } from 'node:test' import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { main } from '../index.js' const realFetch = globalThis.fetch @@ -41,3 +43,14 @@ test('main 在必填環境變數缺失時 reject(供整合層級驗證錯誤處 }) await assert.rejects(() => main(), /GITEA_SERVER_URL is required/) }) + +test('直接執行 index.js 在設定錯誤時以非零狀態碼結束', () => { + const indexPath = fileURLToPath(new URL('../index.js', import.meta.url)) + // 僅帶 PATH,不提供任何必填環境變數,使 main 拋錯並觸發 process.exit(1) + const result = spawnSync(process.execPath, [indexPath], { + env: { PATH: process.env.PATH }, + encoding: 'utf8', + }) + assert.equal(result.status, 1) + assert.match(result.stderr, /GITEA_SERVER_URL is required/) +}) -- 2.53.0 From 941d3c0a8c1bd44f5e33076858577733309ba1a2 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:29:30 +0800 Subject: [PATCH 25/43] =?UTF-8?q?docs(release-cleanup):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README=20cleanupReleases=20=E9=80=A3=E7=B5=90?= =?UTF-8?q?=E8=A1=8C=E8=99=9F=E8=88=87=E6=9B=B4=E6=96=B0=E6=99=82=E9=96=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe9773a..2c46895 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。 -> 更新時間:2026/06/26 11:20:50 +> 更新時間:2026/06/26 11:28:03 ## 專案列表 @@ -47,7 +47,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的 | [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L50) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | | [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L112) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | | [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L14) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) | -| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L35) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | +| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L39) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | | [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L17) | [將 tag 分類為保留、刪除或略過。](#categorizetags) | | [cleanupOrphanTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L42) | [刪除未被任何 release 指定的孤立 tag。](#cleanuporphantags) | -- 2.53.0 From 1cc217f04ee9528f5fb11f6e142569f7fb166891 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:29:30 +0800 Subject: [PATCH 26/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除本輪已修復(Date.parse 預算、結束碼/sanitizeBody/logger 測試)的 finding; 保留 7 條設計取捨類(重試/錯誤閾值/SRP/並行化/回應大小 DoS 防護); 將 6 條不適用或誤報(Node<16、push.apply、'null' 字串、已用 Set、env 可注入、 Set 轉換)登記至 exclusions。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 36 ++++++++++ .gitea/ai-review/findings.json | 114 +++++++------------------------ 2 files changed, 59 insertions(+), 91 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 2cc3ae6..8f27702 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -22,5 +22,41 @@ "role": "Bard", "original_finding": "分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。建議將分隔線管理集中化,並考慮提供動態產生方法。", "reason": "不適用。分隔線已是模組頂層的具名常數(LINE/SUBLINE),本即集中管理且為通用良好實務;固定寬度分隔線改為動態產生方法只會徒增複雜度,無實質效益,故不採納。" + }, + { + "location": "app/gitea-client.js:63", + "role": "Mage", + "original_finding": "while 迴圈中使用 AbortSignal.timeout,若 Node.js 版本低於 16 會崩潰。建議改用 AbortController 搭配 setTimeout。", + "reason": "不適用。本專案以 node:20-alpine 為基底且 package.json engines 限定 >=18,AbortSignal.timeout 自 Node 17.3 起即支援,執行環境保證可用,無向下相容需求。" + }, + { + "location": "app/gitea-client.js:101", + "role": "Rogue", + "original_finding": "處理分頁資料用 for...of 逐一 all.push(item) 效率不佳,建議用 Array.prototype.push.apply(all, items)。", + "reason": "不採納且會造成回歸。先前依 Bard(gitea-client.js:59)建議刻意改用 for...of 以規避展開/apply 在大量項目時的呼叫堆疊上限風險;push.apply 等同展開,會重新引入該風險。逐頁項目數量有限,迴圈開銷可忽略。" + }, + { + "location": "app/validate.js:14", + "role": "Mage", + "original_finding": "isEmptyOrNull 判斷式包含 value === 'null',導致字串 'null' 被判定為空,破壞語義一致性。建議移除對 'null' 字串的特殊判定。", + "reason": "不採納。此為刻意保留原 bash 版本 is_empty_or_null 的語義:Gitea/jq 等來源可能將未設定值輸出為字串 'null',視其為空可避免把字面 'null' 當成有效設定。移除會改變既有行為。" + }, + { + "location": "app/tags.js:46", + "role": "Rogue", + "original_finding": "cleanupOrphanTags 在迴圈中重複掃描所有 release 名稱陣列,複雜度 O(tags*releases),建議改用 Set。", + "reason": "誤報。cleanupOrphanTags 已將 releaseTagNames 交給 categorizeTags,後者於進入迴圈前即建立 Set 進行 O(1) 查找;並無 O(tags*releases) 的重複掃描。" + }, + { + "location": "app/config.js:33", + "role": "Leo", + "original_finding": "直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用,建議抽離為 Provider 或 ConfigFactory。", + "reason": "不適用。loadConfig 已將 env 設為可注入參數(預設 process.env),測試即透過傳入假 env 驗證,已具良好可測性與隔離;對此規模的 Action 引入 Provider/Factory 屬過度設計。" + }, + { + "location": "app/tags.js:21", + "role": "Leo", + "original_finding": "categorizeTags 內部頻繁轉換 Set,若被頻繁呼叫可能造成效能浪費,建議改為接收已轉好的 Set。", + "reason": "不適用。categorizeTags 每次清理流程僅被呼叫一次,單次建立 Set 成本可忽略;讓純函式自行建立 Set 可保持介面單純,將轉換責任外推反而增加耦合。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 6306dff..86a6258 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,40 +1,30 @@ [ { - "level": "critical", + "level": "warning", "role": "Mage", - "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,導致容器無法確保後續清理的一致性與部分成功重試。", - "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略,確保清理任務具備健壯性。", "location": "app/releases.js:38", - "is_new": false - }, - { - "level": "critical", - "role": "Mage", - "location": "app/gitea-client.js:63", - "problem": "在 fetchAllPages 的 while 迴圈中使用了 AbortSignal.timeout。若運行環境的 Node.js 版本低於 16,此程式碼會直接崩潰。", - "suggestion": "確認運行環境的 Node.js 版本,若未來需向下相容,建議使用 AbortController 搭配 setTimeout 手動實作逾時控制。" - }, - { - "level": "critical", - "role": "Maya", - "location": "app/index.js:31", - "problem": "main 函式執行失敗時會觸發 process.exit(1),確保 CI/CD 流程能正確偵測錯誤。目前的測試僅驗證了 Promise 被 reject,但並未驗證程式是否真的正確以非零狀態碼結束。", - "suggestion": "請在 app/test/main.test.js 中,模擬 main 拋出錯誤的情境,並透過 mock process.exit 來驗證當 main 執行失敗時,程式碼確實執行了 process.exit(1)。", - "is_new": true + "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", + "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", + "status": "deferred", + "defer_reason": "與 app/releases.js:46 同屬錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" }, { "level": "warning", "role": "Mage", "location": "app/releases.js:46", - "problem": "刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。若 release 清單非常龐大,會佔用大量記憶體。", - "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試,並引入錯誤閾值。考慮在 fetchAllPages 中加入串流處理(Stream)或實作分批讀取機制,避免將所有資料一次性載入記憶體。" + "problem": "刪除邏輯未針對特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。", + "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試並引入錯誤閾值;資料量龐大時考慮分批/串流讀取。", + "status": "deferred", + "defer_reason": "重試/退避/錯誤閾值涉及次數、間隔與冪等性等設計決策;清理 Action 以排程執行,單次失敗可於下次補刪。分頁已有 MAX_PAGES 上限,實務上 release/tag 數量有限,串流化屬最佳化取捨,保留待人工評估。" }, { "level": "warning", "role": "Leo", "location": "app/releases.js:37", - "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則,且未對 release 物件結構進行進一步驗證,可能導致資料正確性風險。", - "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。建議增加對於 release 物件結構的進一步驗證,並強化檢查邏輯。" + "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則。", + "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", + "status": "deferred", + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑與編碼測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" }, { "level": "warning", @@ -43,68 +33,25 @@ "problem": "清理成品與刪除 tag 使用序列化迴圈,API 請求逐一排隊,整體執行時間拉長。", "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化。", "status": "deferred", - "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。", - "is_new": false - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/gitea-client.js:101", - "problem": "在處理分頁資料時,使用 for...of 逐一執行 all.push(item),導致大量記憶體配置與無謂的物件拷貝,效率不佳。", - "suggestion": "建議直接將 items 整批解構並存入 all,或者使用 Array.prototype.push.apply(all, items) 來減少迴圈開銷。" + "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。" }, { "level": "warning", "role": "Assassin", "location": "app/gitea-client.js:66", - "problem": "攻擊者可透過惡意伺服器回傳極大的錯誤回應內容,利用此處未限制大小的 res.text() 直接讀取整個回應主體,導致記憶體耗盡 (DoS)。", - "suggestion": "應限制讀取的回應大小,例如在請求前檢查 Content-Length 或使用串流處理並設定讀取限制。", - "is_new": true + "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", + "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", + "status": "deferred", + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需以串流逐段讀取並設位元組上限(Content-Length 在 chunked 回應下不可靠),屬較大改動,保留待人工評估。" }, { "level": "warning", "role": "Assassin", "location": "app/gitea-client.js:77", - "problem": "攻擊者可透過惡意伺服器回傳極大的 JSON 資料,利用此處未限制大小的 res.json() 直接解析整個回應,導致記憶體耗盡 (DoS)。", - "suggestion": "應對 API 回應設定明確的大小上限,超過時拒絕解析並拋出異常。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/validate.js:14", - "problem": "isEmptyOrNull 判斷式包含 value === 'null',導致正確字串內容 'null' 被判定為空,破壞語義一致性。", - "suggestion": "建議移除對 'null' 字串的特殊判定,改由呼叫端明確處理邏輯。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/gitea-client.js:13", - "problem": "sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。", - "suggestion": "請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/logger.js:9", - "problem": "logger.js 中的輸出函式(如 separator, section, info, success, warn, fail)負責 Action 的核心視覺輸出格式,但目前缺乏測試驗證其實際輸出內容是否正確對齊並符合格式要求。", - "suggestion": "請在 app/test/logger.test.js 中補齊對這些函式的測試,驗證其是否正確寫入預期的格式內容(含分隔線與正確的前綴)到 stdout。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/releases.js:17", - "problem": "selectReleasesToDelete 針對每一筆 release 重複執行 Date.parse,浪費 CPU 週期。", - "suggestion": "應在排序前先執行一次 map 轉換,預先計算時間戳記。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/tags.js:46", - "problem": "cleanupOrphanTags 在迴圈中重複掃描所有 release 名稱陣列,時間複雜度為 O(tags * releases),效能低落。", - "suggestion": "應在進入 tag 迴圈前,先將所有 release 的 tag 名稱轉成一個 Set 物件,將查找複雜度降為 O(1)。" + "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", + "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", + "status": "deferred", + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" }, { "level": "info", @@ -113,21 +60,6 @@ "problem": "fetchAllPages 採線性逐頁請求,資料龐大時效能不佳。", "suggestion": "若 API 支援,先取得總頁數後並行請求。", "status": "deferred", - "defer_reason": "Gitea 分頁未可靠提供總頁數,需解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時與 MAX_PAGES 上限已足夠穩健,並行化屬最佳化取捨,保留待人工評估。", - "is_new": false - }, - { - "level": "info", - "role": "Leo", - "location": "app/config.js:33", - "problem": "直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用。", - "suggestion": "建議在複雜系統中,將環境變數讀取抽離為獨立的 Provider 或 ConfigFactory。" - }, - { - "level": "info", - "role": "Leo", - "location": "app/tags.js:21", - "problem": "categorizeTags 內部頻繁轉換 Set,若此函式被頻繁呼叫,可能造成效能浪費。", - "suggestion": "建議調整設計,讓 categorizeTags 接收已經轉好的 Set,將轉換開銷移至上層。" + "defer_reason": "Gitea 分頁未可靠提供總頁數,需解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時與 MAX_PAGES 上限已足夠穩健,並行化屬最佳化取捨,保留待人工評估。" } ] -- 2.53.0 From 76bccfeea92ecfd6bbfa889ec727166491ed4e66 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:30:27 +0000 Subject: [PATCH 27/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 143 ++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 19 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 86a6258..9a554df 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,4 +1,42 @@ [ + { + "level": "critical", + "role": "Assassin", + "location": "app/releases.js:48", + "problem": "攻擊者可以透過控制 GITEA_REPOSITORY 環境變數,在其名稱中包含路徑穿越序列(如 '../'),進而操控 API 的刪除目標,造成越權刪除其他儲存庫的成品。", + "suggestion": "在將 id 拼接進 URL 前,務必使用 encodeURIComponent(id) 對 id 進行編碼,確保其僅被視為路徑的一部分,而非路徑控制字元。", + "is_new": true + }, + { + "level": "critical", + "role": "Assassin", + "location": "app/tags.js:70", + "problem": "攻擊者可以透過控制 GITEA_REPOSITORY 環境變數,在其名稱中包含路徑穿越序列,進而操控 API 的刪除目標;同時,tag 名稱若包含惡意字元也可能造成路徑穿越。", + "suggestion": "同樣地,在將 tag.name 拼接進 URL 前,必須使用 encodeURIComponent(tag.name) 對其進行編碼。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "problem": "main 函式執行失敗時會觸發 process.exit(1),確保 CI/CD 流程能正確偵測錯誤。目前的測試僅驗證了 Promise 被 reject,但並未驗證程式是否真的正確以非零狀態碼結束。", + "suggestion": "請在 app/test/main.test.js 中,模擬 main 拋出錯誤的情境,並透過 mock process.exit 來驗證當 main 執行失敗時,程式碼確實執行了 process.exit(1)。", + "location": "app/index.js:31", + "is_new": false + }, + { + "level": "critical", + "role": "Maya", + "location": "app/test/config.test.js:63", + "problem": "測試只驗證了正常與極端的 token 輸入,但完全沒有測試 token 為 null 或未定義(匿名模式)下的處理邏輯,也沒確認匿名請求時,設定物件是否正確地將 token 設為 null。", + "suggestion": "請增加測試案例,明確斷言當 GITEA_TOKEN 不存在於環境變數時,loadConfig() 回傳的 token 屬性為 null。" + }, + { + "level": "critical", + "role": "Maya", + "location": "app/test/gitea-client.test.js:33", + "problem": "測試中雖然有 fetchAllPages 的功能測試,但對於 MAX_PAGES 的邊界情況,測試只用了 globalThis.fetch 永遠回傳非空陣列,這是一個快樂路徑的極端變體。如果 API 剛好在第 MAX_PAGES 頁回傳空陣列,測試並未驗證客戶端是否能正確處理並停止。", + "suggestion": "請增加一個測試案例,模擬當 API 恰好在第 MAX_PAGES 次請求時回傳空陣列的情境,確認客戶端能成功結束,而非拋出例外。" + }, { "level": "warning", "role": "Mage", @@ -6,16 +44,8 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "與 app/releases.js:46 同屬錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/releases.js:46", - "problem": "刪除邏輯未針對特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。", - "suggestion": "針對特定 HTTP 狀態碼實作指數退避重試並引入錯誤閾值;資料量龐大時考慮分批/串流讀取。", - "status": "deferred", - "defer_reason": "重試/退避/錯誤閾值涉及次數、間隔與冪等性等設計決策;清理 Action 以排程執行,單次失敗可於下次補刪。分頁已有 MAX_PAGES 上限,實務上 release/tag 數量有限,串流化屬最佳化取捨,保留待人工評估。" + "defer_reason": "與 app/releases.js:46 同屬錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -24,7 +54,8 @@ "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則。", "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑與編碼測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑與編碼測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -33,7 +64,8 @@ "problem": "清理成品與刪除 tag 使用序列化迴圈,API 請求逐一排隊,整體執行時間拉長。", "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化。", "status": "deferred", - "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。" + "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。", + "is_new": false }, { "level": "warning", @@ -42,7 +74,8 @@ "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需以串流逐段讀取並設位元組上限(Content-Length 在 chunked 回應下不可靠),屬較大改動,保留待人工評估。" + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需以串流逐段讀取並設位元組上限(Content-Length 在 chunked 回應下不可靠),屬較大改動,保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -51,15 +84,87 @@ "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。", + "is_new": false + }, + { + "level": "warning", + "role": "Maya", + "problem": "logger.js 中的輸出函式(如 separator, section, info, success, warn, fail)負責 Action 的核心視覺輸出格式,但目前缺乏測試驗證其實際輸出內容是否正確對齊並符合格式要求。", + "suggestion": "請在 app/test/logger.test.js 中補齊對這些函式的測試,驗證其是否正確寫入預期的格式內容(含分隔線與正確的前綴)到 stdout。", + "location": "app/logger.js:9", + "is_new": false + }, + { + "level": "warning", + "role": "Maya", + "problem": "sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。", + "suggestion": "請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。", + "location": "app/gitea-client.js:13", + "is_new": false + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:59", + "problem": "使用字串模板直接拼接 API URL:`const url = `${config.releaseApiUrl}/${id}`;`。若 API 回傳的 id 包含 `/` 或特殊字元,可能導致拼接出錯誤的 API 路徑,甚至在某些處理器上導致路徑穿越,雖是 Gitea API 但應防禦性地進行 URL 編碼。", + "suggestion": "建議使用 encodeURIComponent(id) 對 id 進行編碼後再拼接。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/tags.js:65", + "problem": "與 releases.js 相同問題,在拼接 tag URL 時:`const url = `${config.tagApiUrl}/${tag.name}`;` 未對 tag 名稱進行 URL 編碼。Tag 名稱若包含 `/` 等特殊字元,會破壞 URL 結構。", + "suggestion": "建議使用 encodeURIComponent(tag.name) 對 tag 名稱進行編碼。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/test/releases.test.js:77", + "problem": "在 `cleanupReleases` 的整合測試中,雖然有測試 `deleteResource` 回傳非 204 時的行為,但測試只檢查了 `client.deleted` 的呼叫順序,並沒有驗證 `fail` 日誌(對 stderr 的寫入)是否正確被觸發。", + "suggestion": "建議攔截 `process.stderr.write`,確認當 `deleteResource` 回傳 500 時,系統確實有記錄到錯誤訊息。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/test/tags.test.js:73", + "problem": "在 `cleanupOrphanTags` 的測試中,缺乏對於「當 `deleteResource` 失敗」時的行為驗證。目前只測試了成功的情境。", + "suggestion": "增加模擬 `deleteResource` 回傳非 204 狀態碼的情境,確認該項刪除失敗不會中斷整個標籤清理流程,並檢查是否有對應的錯誤日誌。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "app/releases.js:28", + "problem": "在 `cleanupReleases` 函式中,參數 `config` 是 `ReturnType`,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。", + "suggestion": "建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "app/gitea-client.js:25", + "problem": "在 `GiteaClient` 建構子中,`this.headers` 初始化時僅簡單檢查 `token` 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。", + "suggestion": "考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 `_getHeaders()`),即便現在很簡單,也能增加未來的彈性。", + "is_new": true + }, + { + "level": "info", + "role": "Maya", + "location": "app/test/validate.test.js:20", + "problem": "雖然有 `requireInteger` 的測試,但沒有測試當 `KEEP_COUNT` 為 `0` 時的邊界情況。雖然 0 在邏輯上可能是允許的,但這對於清理邏輯來說是個關鍵的邊界。", + "suggestion": "明確測試 `requireInteger('KEEP_COUNT', '0')` 並斷言其不應拋出錯誤,確保系統允許保留 0 個成品的配置。", + "is_new": true }, { "level": "info", "role": "Rogue", - "location": "app/gitea-client.js:33", - "problem": "fetchAllPages 採線性逐頁請求,資料龐大時效能不佳。", - "suggestion": "若 API 支援,先取得總頁數後並行請求。", - "status": "deferred", - "defer_reason": "Gitea 分頁未可靠提供總頁數,需解析 Link 標頭且各端點支援度不一;線性逐頁搭配逾時與 MAX_PAGES 上限已足夠穩健,並行化屬最佳化取捨,保留待人工評估。" + "location": "app/releases.js:23", + "problem": "為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。", + "suggestion": "排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。", + "is_new": true } ] -- 2.53.0 From fd2dfa87321bd740f8079d439044011086958324 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:33:45 +0800 Subject: [PATCH 28/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=20MAX=5FPAGES=20=E9=82=8A=E7=95=8C=E8=88=87=E5=88=AA?= =?UTF-8?q?=E9=99=A4=E5=A4=B1=E6=95=97=E8=A8=98=E9=8C=84=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 fetchAllPages 於第 MAX_PAGES 頁回空陣列時正常結束的測試,以及 cleanupReleases/cleanupOrphanTags 在 deleteResource 回非 204 時繼續處理 並寫入 stderr 錯誤記錄的測試。測試共 59 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/gitea-client.test.js | 12 ++++++++++++ app/test/releases.test.js | 22 ++++++++++++++++++++++ app/test/tags.test.js | 27 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js index deb6c01..460391f 100644 --- a/app/test/gitea-client.test.js +++ b/app/test/gitea-client.test.js @@ -142,6 +142,18 @@ test('fetchAllPages 的錯誤訊息會清理控制字元並限制長度(sanitize assert.ok((message.match(/x/g) || []).length <= 200) }) +test('fetchAllPages 在第 MAX_PAGES 頁回傳空陣列時正常結束(不拋例外)', async () => { + let page = 0 + globalThis.fetch = async () => { + page += 1 + // 前 999 頁有資料,第 1000 頁(MAX_PAGES)回空陣列,應正常結束 + return jsonResponse(page < 1000 ? [{ id: page }] : []) + } + const client = new GiteaClient({}) + const items = await client.fetchAllPages('https://example.com/api') + assert.equal(items.length, 999) +}) + test('deleteResource 回傳 HTTP 狀態碼', async () => { globalThis.fetch = async (url, opts) => { assert.equal(opts.method, 'DELETE') diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 0f3e53b..500b671 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -103,6 +103,28 @@ test('cleanupReleases 對 id 進行 URL 編碼(防路徑穿越)', async () => { assert.deepEqual(client.deleted, ['http://x/releases/..%2Fevil']) }) +test('cleanupReleases 在刪除回傳非 204 時記錄錯誤至 stderr', async () => { + const client = fakeClient( + [ + { id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + { id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, + ], + () => 500, + ) + const realWrite = process.stderr.write + const chunks = [] + process.stderr.write = (chunk) => { + chunks.push(String(chunk)) + return true + } + try { + await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) + } finally { + process.stderr.write = realWrite + } + assert.match(chunks.join(''), /\[ERR\]/) +}) + test('cleanupReleases 略過沒有 id 的 release', async () => { const client = fakeClient([ { id: 1, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, diff --git a/app/test/tags.test.js b/app/test/tags.test.js index 93c1988..226c4f9 100644 --- a/app/test/tags.test.js +++ b/app/test/tags.test.js @@ -87,6 +87,33 @@ test('cleanupOrphanTags 在沒有任何 release 時刪除所有具名 tag', asyn assert.deepEqual(client.deleted, ['http://x/tags/v2.0.0', 'http://x/tags/v1.0.0']) }) +test('cleanupOrphanTags 在刪除回傳非 204 時繼續處理其餘 tag 並記錄錯誤', async () => { + const deleted = [] + const client = { + async fetchAllPages(url) { + return url.includes('/releases') ? [] : [{ name: 'v1' }, { name: 'v2' }] + }, + async deleteResource(url) { + deleted.push(url) + return 500 + }, + } + const realWrite = process.stderr.write + const chunks = [] + process.stderr.write = (chunk) => { + chunks.push(String(chunk)) + return true + } + try { + await cleanupOrphanTags(client, config) + } finally { + process.stderr.write = realWrite + } + // 兩個孤立 tag 都被嘗試刪除(失敗不中斷),且有錯誤記錄 + assert.equal(deleted.length, 2) + assert.match(chunks.join(''), /\[ERR\]/) +}) + test('cleanupOrphanTags 對含特殊字元的 tag 名稱進行 URL 編碼(防路徑穿越)', async () => { const client = fakeClient({ releases: [], -- 2.53.0 From b57180153eb8f629565c220e8dd6eea8869cabd7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:33:45 +0800 Subject: [PATCH 29/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除本輪已具測試覆蓋的 finding(MAX_PAGES 邊界、刪除失敗記錄)與多項 先前已修復卻被重複提報者;保留 6 條設計取捨類待人工評估;新增 2 條不適用 (_getHeaders 過度設計、排序中間陣列與前次優化衝突)至 exclusions。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 12 +++ .gitea/ai-review/findings.json | 132 +++---------------------------- 2 files changed, 21 insertions(+), 123 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 8f27702..5b5192d 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -58,5 +58,17 @@ "role": "Leo", "original_finding": "categorizeTags 內部頻繁轉換 Set,若被頻繁呼叫可能造成效能浪費,建議改為接收已轉好的 Set。", "reason": "不適用。categorizeTags 每次清理流程僅被呼叫一次,單次建立 Set 成本可忽略;讓純函式自行建立 Set 可保持介面單純,將轉換責任外推反而增加耦合。" + }, + { + "location": "app/gitea-client.js:25", + "role": "Bard", + "original_finding": "GiteaClient 建構子的 header 初始化結構死板,建議抽離為內部 private 函式 _getHeaders() 以增加未來彈性。", + "reason": "YAGNI / 不適用。目前僅單一固定標頭,建構子直接設定清楚直觀;為尚不存在的需求預先抽象屬過度設計,待真有多標頭需求時再重構。" + }, + { + "location": "app/releases.js:23", + "role": "Rogue", + "original_finding": "為排序而 map 成物件陣列再 map 回原物件,造成多次 O(n) 陣列分配與 GC 壓力,建議減少中間狀態或原地排序。", + "reason": "不採納且與前次建議衝突。此 map/sort/map(Schwartzian transform)正是前一輪 Rogue(releases.js:17)要求「排序前預先計算時間戳」的實作,用以避免比較器重複呼叫 Date.parse;release 數量有限,中間陣列開銷可忽略,可讀性更佳。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 9a554df..af17419 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,42 +1,4 @@ [ - { - "level": "critical", - "role": "Assassin", - "location": "app/releases.js:48", - "problem": "攻擊者可以透過控制 GITEA_REPOSITORY 環境變數,在其名稱中包含路徑穿越序列(如 '../'),進而操控 API 的刪除目標,造成越權刪除其他儲存庫的成品。", - "suggestion": "在將 id 拼接進 URL 前,務必使用 encodeURIComponent(id) 對 id 進行編碼,確保其僅被視為路徑的一部分,而非路徑控制字元。", - "is_new": true - }, - { - "level": "critical", - "role": "Assassin", - "location": "app/tags.js:70", - "problem": "攻擊者可以透過控制 GITEA_REPOSITORY 環境變數,在其名稱中包含路徑穿越序列,進而操控 API 的刪除目標;同時,tag 名稱若包含惡意字元也可能造成路徑穿越。", - "suggestion": "同樣地,在將 tag.name 拼接進 URL 前,必須使用 encodeURIComponent(tag.name) 對其進行編碼。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "problem": "main 函式執行失敗時會觸發 process.exit(1),確保 CI/CD 流程能正確偵測錯誤。目前的測試僅驗證了 Promise 被 reject,但並未驗證程式是否真的正確以非零狀態碼結束。", - "suggestion": "請在 app/test/main.test.js 中,模擬 main 拋出錯誤的情境,並透過 mock process.exit 來驗證當 main 執行失敗時,程式碼確實執行了 process.exit(1)。", - "location": "app/index.js:31", - "is_new": false - }, - { - "level": "critical", - "role": "Maya", - "location": "app/test/config.test.js:63", - "problem": "測試只驗證了正常與極端的 token 輸入,但完全沒有測試 token 為 null 或未定義(匿名模式)下的處理邏輯,也沒確認匿名請求時,設定物件是否正確地將 token 設為 null。", - "suggestion": "請增加測試案例,明確斷言當 GITEA_TOKEN 不存在於環境變數時,loadConfig() 回傳的 token 屬性為 null。" - }, - { - "level": "critical", - "role": "Maya", - "location": "app/test/gitea-client.test.js:33", - "problem": "測試中雖然有 fetchAllPages 的功能測試,但對於 MAX_PAGES 的邊界情況,測試只用了 globalThis.fetch 永遠回傳非空陣列,這是一個快樂路徑的極端變體。如果 API 剛好在第 MAX_PAGES 頁回傳空陣列,測試並未驗證客戶端是否能正確處理並停止。", - "suggestion": "請增加一個測試案例,模擬當 API 恰好在第 MAX_PAGES 次請求時回傳空陣列的情境,確認客戶端能成功結束,而非拋出例外。" - }, { "level": "warning", "role": "Mage", @@ -44,8 +6,7 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "與 app/releases.js:46 同屬錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", - "is_new": false + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" }, { "level": "warning", @@ -54,8 +15,7 @@ "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則。", "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑與編碼測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。", - "is_new": false + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑/編碼/stderr 測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" }, { "level": "warning", @@ -64,8 +24,7 @@ "problem": "清理成品與刪除 tag 使用序列化迴圈,API 請求逐一排隊,整體執行時間拉長。", "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化。", "status": "deferred", - "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。", - "is_new": false + "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。" }, { "level": "warning", @@ -74,8 +33,7 @@ "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需以串流逐段讀取並設位元組上限(Content-Length 在 chunked 回應下不可靠),屬較大改動,保留待人工評估。", - "is_new": false + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需串流逐段讀取並設位元組上限(Content-Length 在 chunked 下不可靠),屬較大改動,保留待人工評估。" }, { "level": "warning", @@ -84,87 +42,15 @@ "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。", - "is_new": false - }, - { - "level": "warning", - "role": "Maya", - "problem": "logger.js 中的輸出函式(如 separator, section, info, success, warn, fail)負責 Action 的核心視覺輸出格式,但目前缺乏測試驗證其實際輸出內容是否正確對齊並符合格式要求。", - "suggestion": "請在 app/test/logger.test.js 中補齊對這些函式的測試,驗證其是否正確寫入預期的格式內容(含分隔線與正確的前綴)到 stdout。", - "location": "app/logger.js:9", - "is_new": false - }, - { - "level": "warning", - "role": "Maya", - "problem": "sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。", - "suggestion": "請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。", - "location": "app/gitea-client.js:13", - "is_new": false - }, - { - "level": "warning", - "role": "Mage", - "location": "app/releases.js:59", - "problem": "使用字串模板直接拼接 API URL:`const url = `${config.releaseApiUrl}/${id}`;`。若 API 回傳的 id 包含 `/` 或特殊字元,可能導致拼接出錯誤的 API 路徑,甚至在某些處理器上導致路徑穿越,雖是 Gitea API 但應防禦性地進行 URL 編碼。", - "suggestion": "建議使用 encodeURIComponent(id) 對 id 進行編碼後再拼接。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/tags.js:65", - "problem": "與 releases.js 相同問題,在拼接 tag URL 時:`const url = `${config.tagApiUrl}/${tag.name}`;` 未對 tag 名稱進行 URL 編碼。Tag 名稱若包含 `/` 等特殊字元,會破壞 URL 結構。", - "suggestion": "建議使用 encodeURIComponent(tag.name) 對 tag 名稱進行編碼。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/test/releases.test.js:77", - "problem": "在 `cleanupReleases` 的整合測試中,雖然有測試 `deleteResource` 回傳非 204 時的行為,但測試只檢查了 `client.deleted` 的呼叫順序,並沒有驗證 `fail` 日誌(對 stderr 的寫入)是否正確被觸發。", - "suggestion": "建議攔截 `process.stderr.write`,確認當 `deleteResource` 回傳 500 時,系統確實有記錄到錯誤訊息。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/test/tags.test.js:73", - "problem": "在 `cleanupOrphanTags` 的測試中,缺乏對於「當 `deleteResource` 失敗」時的行為驗證。目前只測試了成功的情境。", - "suggestion": "增加模擬 `deleteResource` 回傳非 204 狀態碼的情境,確認該項刪除失敗不會中斷整個標籤清理流程,並檢查是否有對應的錯誤日誌。", - "is_new": true + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" }, { "level": "info", "role": "Bard", "location": "app/releases.js:28", - "problem": "在 `cleanupReleases` 函式中,參數 `config` 是 `ReturnType`,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。", - "suggestion": "建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "app/gitea-client.js:25", - "problem": "在 `GiteaClient` 建構子中,`this.headers` 初始化時僅簡單檢查 `token` 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。", - "suggestion": "考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 `_getHeaders()`),即便現在很簡單,也能增加未來的彈性。", - "is_new": true - }, - { - "level": "info", - "role": "Maya", - "location": "app/test/validate.test.js:20", - "problem": "雖然有 `requireInteger` 的測試,但沒有測試當 `KEEP_COUNT` 為 `0` 時的邊界情況。雖然 0 在邏輯上可能是允許的,但這對於清理邏輯來說是個關鍵的邊界。", - "suggestion": "明確測試 `requireInteger('KEEP_COUNT', '0')` 並斷言其不應拋出錯誤,確保系統允許保留 0 個成品的配置。", - "is_new": true - }, - { - "level": "info", - "role": "Rogue", - "location": "app/releases.js:23", - "problem": "為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。", - "suggestion": "排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。", - "is_new": true + "problem": "config 參數型別 ReturnType 雖準確但冗長,與實作細節耦合,影響可讀性。", + "suggestion": "在 config.js 定義並匯出 JSDoc @typedef,於各處改用該別名。", + "status": "deferred", + "defer_reason": "純可讀性偏好,現有型別註解準確且可運作;引入 @typedef 會牽動多處 JSDoc 與 README 行號,效益有限,保留待人工評估。" } ] -- 2.53.0 From e0ab6f2693fd5d74a6116947489fb6f7c2279ffa Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:34:26 +0000 Subject: [PATCH 30/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 130 ++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index af17419..7c766ec 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,4 +1,28 @@ [ + { + "level": "critical", + "role": "Maya", + "problem": "測試只驗證了正常與極端的 token 輸入,但完全沒有測試 token 為 null 或未定義(匿名模式)下的處理邏輯,也沒確認匿名請求時,設定物件是否正確地將 token 設為 null。", + "suggestion": "請增加測試案例,明確斷言當 GITEA_TOKEN 不存在於環境變數時,loadConfig() 回傳的 token 屬性為 null。", + "location": "app/test/config.test.js:63", + "is_new": false + }, + { + "level": "critical", + "role": "Maya", + "problem": "測試中雖然有 fetchAllPages 的功能測試,但對於 MAX_PAGES 的邊界情況,測試只用了 globalThis.fetch 永遠回傳非空陣列,這是一個快樂路徑的極端變體。如果 API 剛好在第 MAX_PAGES 頁回傳空陣列,測試並未驗證客戶端是否能正確處理並停止。", + "suggestion": "請增加一個測試案例,模擬當 API 恰好在第 MAX_PAGES 次請求時回傳空陣列的情境,確認客戶端能成功結束,而非拋出例外。", + "location": "app/test/gitea-client.test.js:33", + "is_new": false + }, + { + "level": "critical", + "role": "Assassin", + "location": "app/config.js:35", + "problem": "僅驗證是否為 URL 格式,未檢查傳入的 `GITEA_SERVER_URL` 是否指向內部網路敏感資源(如 localhost, 169.254.169.254 等),這在容器化環境中可能導致 SSRF(伺服器端請求偽造)。", + "suggestion": "在 `validate.js` 的 `requireUrl` 中加入黑名單機制,禁止解析為內部 IP 位址或 loopback 位址。", + "is_new": true + }, { "level": "warning", "role": "Mage", @@ -6,7 +30,8 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -15,16 +40,8 @@ "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則。", "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑/編碼/stderr 測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/releases.js:43", - "problem": "清理成品與刪除 tag 使用序列化迴圈,API 請求逐一排隊,整體執行時間拉長。", - "suggestion": "改用 Promise.all 搭配 map 將刪除請求並行化。", - "status": "deferred", - "defer_reason": "刻意保留序列化:避免對 Gitea API 造成併發壓力與觸發速率限制,並維持可預期的記錄輸出順序;無上限並行化非等價變更,保留待人工評估(可日後改為有上限並行)。" + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑/編碼/stderr 測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -33,7 +50,8 @@ "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需串流逐段讀取並設位元組上限(Content-Length 在 chunked 下不可靠),屬較大改動,保留待人工評估。" + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需串流逐段讀取並設位元組上限(Content-Length 在 chunked 下不可靠),屬較大改動,保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -42,15 +60,93 @@ "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。", + "is_new": false + }, + { + "level": "warning", + "role": "Maya", + "problem": "在 `cleanupReleases` 的整合測試中,雖然有測試 `deleteResource` 回傳非 204 時的行為,但測試只檢查了 `client.deleted` 的呼叫順序,並沒有驗證 `fail` 日誌(對 stderr 的寫入)是否正確被觸發。", + "suggestion": "建議攔截 `process.stderr.write`,確認當 `deleteResource` 回傳 500 時,系統確實有記錄到錯誤訊息。", + "location": "app/test/releases.test.js:77", + "is_new": false + }, + { + "level": "warning", + "role": "Maya", + "problem": "在 `cleanupOrphanTags` 的測試中,缺乏對於「當 `deleteResource` 失敗」時的行為驗證。目前只測試了成功的情境。", + "suggestion": "增加模擬 `deleteResource` 回傳非 204 狀態碼的情境,確認該項刪除失敗不會中斷整個標籤清理流程,並檢查是否有對應的錯誤日誌。", + "location": "app/test/tags.test.js:73", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/config.js:34", + "problem": "將環境變數值直接輸出至日誌,若 `GITEA_SERVER_URL` 等變數內容被注入惡意字元或過長,可能造成 Log Injection 或日誌系統資源耗盡。", + "suggestion": "在輸出前進行 sanitization,移除控制字元並限制長度,類似於 `gitea-client.js` 中的 `sanitizeBody`。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/gitea-client.js:106", + "problem": "將 `items` 直接放入 `all` 陣列。若 API 返回異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。(註: 亦包含 Bard 關於 for...of/push 的效能建議)", + "suggestion": "考慮在 `fetchAllPages` 中增加最大總項目數量的限制,並在超過時拋出錯誤;同時建議使用 `AsyncGenerator` 進行串流式處理以降低記憶體佔用。" + }, + { + "level": "warning", + "role": "Bard", + "location": "app/releases.js:46", + "problem": "區段標題『取得成品資訊』與後續的 `section('刪除舊版本成品')` 使用了不同的區段層級。第一個區段內包含了 `info` 輸出,而第二個區段直接開始處理邏輯,視覺節奏上稍微不一致。", + "suggestion": "建議在所有主要的操作階段前統一呼叫 `section()`,或在細部操作前使用更明確的層級標示,保持日誌格式的旋律一致性。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/releases.js:20", + "problem": "在 `selectReleasesToDelete` 函式中,對於 `Date.parse` 無法解析的日期直接視為 `0`,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。", + "suggestion": "應明確記錄無效日期的處理方式(例如記錄 warning),或在 `Date.parse` 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:23", + "problem": "在 `selectReleasesToDelete` 的排序邏輯中,若多個成品具有完全相同的 `created_at` 時間戳,目前的排序行為依賴於 JavaScript 引擎對 `sort()` 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。", + "suggestion": "建議在排序邏輯中加入次要的排序鍵值(如 `id` 或 `tag_name`)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。", + "is_new": true }, { "level": "info", "role": "Bard", "location": "app/releases.js:28", - "problem": "config 參數型別 ReturnType 雖準確但冗長,與實作細節耦合,影響可讀性。", - "suggestion": "在 config.js 定義並匯出 JSDoc @typedef,於各處改用該別名。", - "status": "deferred", - "defer_reason": "純可讀性偏好,現有型別註解準確且可運作;引入 @typedef 會牽動多處 JSDoc 與 README 行號,效益有限,保留待人工評估。" + "problem": "config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。", + "suggestion": "建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。" + }, + { + "level": "info", + "role": "Assassin", + "location": "app/logger.js:53", + "problem": "儘管使用了 `stderr` 輸出錯誤,但在 `failError` 中直接輸出 `error.stack` 可能會洩漏專案目錄結構、內部函式名稱等敏感路徑資訊。", + "suggestion": "在生產環境下考慮隱藏堆疊追蹤,或僅在特定 debug 模式下輸出堆疊。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/gitea-client.js:10", + "problem": "目前 `MAX_PAGES` 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。", + "suggestion": "建議將 `MAX_PAGES` 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/logger.js:46", + "problem": "雖然目前只有 `fail` 函式會寫入 `stderr`,但如果有更多的 error level 需要處理,分散的邏輯會增加維護成本。", + "suggestion": "考慮在 `logger.js` 中建立一個通用的 `log` 函式,處理 `level`、`prefix` 與 `stream` 的對應,讓其他方法(如 `info`, `warn`, `fail`)只負責呼叫該通用函式,降低重複程式碼。", + "is_new": true } ] -- 2.53.0 From 9e33cb8ba89885f1ae8c05ac6e175bab4b6df9f0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:43:16 +0800 Subject: [PATCH 31/43] =?UTF-8?q?fix(release-cleanup):=20selectReleasesToD?= =?UTF-8?q?elete=20=E5=9C=A8=E6=99=82=E9=96=93=E7=9B=B8=E5=90=8C=E6=99=82?= =?UTF-8?q?=E4=BB=A5=20id=20=E6=B1=BA=E5=AE=9A=E9=A0=86=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排序加入次要鍵(id 數值,大者視為新),避免多筆 created_at 相同時保留/ 刪除的選擇因引擎排序穩定性而不確定。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/releases.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/releases.js b/app/releases.js index c975fe5..2c65042 100644 --- a/app/releases.js +++ b/app/releases.js @@ -18,9 +18,14 @@ export function selectReleasesToDelete(releases, keepCount) { return Number.isNaN(time) ? 0 : time } // 先一次性計算每筆的時間戳,避免在排序比較中重複呼叫 Date.parse。 + // 時間相同時以 id(數值,大者為新)作為次要鍵,確保排序結果具決定性。 return releases - .map((release) => ({ release, time: createdTime(release) })) - .sort((a, b) => b.time - a.time) + .map((release) => ({ + release, + time: createdTime(release), + id: Number(release?.id) || 0, + })) + .sort((a, b) => b.time - a.time || b.id - a.id) .map((entry) => entry.release) .slice(keepCount) } -- 2.53.0 From 858e628db6093e2ece4409cd8c2ff338560b39e9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:43:16 +0800 Subject: [PATCH 32/43] =?UTF-8?q?test(release-cleanup):=20=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E7=9B=B8=E5=90=8C=20created=5Fat=20=E6=99=82=E7=9A=84?= =?UTF-8?q?=E6=B1=BA=E5=AE=9A=E6=80=A7=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增測試:多筆 created_at 相同時,以 id 大小決定保留/刪除順序。測試共 60 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/releases.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 500b671..a55964b 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -27,6 +27,21 @@ test('selectReleasesToDelete 在 keepCount 為 0 時刪除全部(由新到舊)', ) }) +test('selectReleasesToDelete 在 created_at 相同時以 id 決定順序(具決定性)', () => { + const same = '2024-01-01T00:00:00Z' + const list = [ + { id: 1, created_at: same }, + { id: 3, created_at: same }, + { id: 2, created_at: same }, + ] + const toDelete = selectReleasesToDelete(list, 1) + // 保留 id 最大者(3),刪除 id 2 與 1(順序固定) + assert.deepEqual( + toDelete.map((r) => r.id), + [2, 1], + ) +}) + test('selectReleasesToDelete 不改動原陣列', () => { const original = [...releases] selectReleasesToDelete(releases, 1) -- 2.53.0 From de094c542f95001ef44db935bf1152a0f661206d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:43:16 +0800 Subject: [PATCH 33/43] =?UTF-8?q?docs(release-cleanup):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README=20cleanupReleases=20=E9=80=A3=E7=B5=90?= =?UTF-8?q?=E8=A1=8C=E8=99=9F=E8=88=87=E6=9B=B4=E6=96=B0=E6=99=82=E9=96=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c46895..51635b9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。 -> 更新時間:2026/06/26 11:28:03 +> 更新時間:2026/06/26 11:42:02 ## 專案列表 @@ -47,7 +47,7 @@ release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的 | [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L50) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) | | [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L112) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) | | [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L14) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) | -| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L39) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | +| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L44) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) | | [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L17) | [將 tag 分類為保留、刪除或略過。](#categorizetags) | | [cleanupOrphanTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L42) | [刪除未被任何 release 指定的孤立 tag。](#cleanuporphantags) | -- 2.53.0 From b461f72d4368b8baa038b7694f16aba3202638be Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:43:16 +0800 Subject: [PATCH 34/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除已修復(決定性排序)與多項重複提報的 finding;保留 7 條設計取捨; 新增 6 條不適用/誤報至 exclusions(內部 IP 黑名單會破壞自架 Gitea、 section 排版主觀、無效日期已註記、CI 堆疊輸出無妨、MAX_PAGES 為安全上限、 通用 log 抽象效益低)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 36 ++++++++++ .gitea/ai-review/findings.json | 119 +++++-------------------------- 2 files changed, 52 insertions(+), 103 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 5b5192d..ae4eff0 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -70,5 +70,41 @@ "role": "Rogue", "original_finding": "為排序而 map 成物件陣列再 map 回原物件,造成多次 O(n) 陣列分配與 GC 壓力,建議減少中間狀態或原地排序。", "reason": "不採納且與前次建議衝突。此 map/sort/map(Schwartzian transform)正是前一輪 Rogue(releases.js:17)要求「排序前預先計算時間戳」的實作,用以避免比較器重複呼叫 Date.parse;release 數量有限,中間陣列開銷可忽略,可讀性更佳。" + }, + { + "location": "app/config.js:35", + "role": "Assassin", + "original_finding": "requireUrl 僅驗證 URL 格式,未阻擋指向內部網路/loopback(localhost、169.254.169.254 等)的位址,容器環境恐 SSRF。建議加入內部 IP 黑名單。", + "reason": "不適用且會破壞正常部署。GITEA_SERVER_URL 來自 CI 平台提供的 gitea.server_url,即本 Action 欲操作的 Gitea 實例本身;自架 Gitea 經常部署於內網/私有位址,封鎖內部 IP 會使合法部署無法運作。此非攻擊者可控的任意外連目標。" + }, + { + "location": "app/releases.js:46", + "role": "Bard", + "original_finding": "區段標題層級不一致:『取得成品資訊』內含 info 輸出,『刪除舊版本成品』直接開始邏輯,視覺節奏稍不一致。", + "reason": "主觀排版意見。現行 section() 用於標示每個主要階段,語意清楚且符合原 bash 版本結構;不構成功能或可讀性問題。" + }, + { + "location": "app/releases.js:20", + "role": "Leo", + "original_finding": "selectReleasesToDelete 對無法解析的日期視為 0 是隱晦行為,維護者可能不解,建議記錄 warning 或明確處理。", + "reason": "已於程式碼註解明確記錄『格式無效或缺漏時視為最舊(0)』;Gitea API 一向回傳有效 created_at,對每筆無效日期發出 runtime warning 將是不會觸發的死碼與雜訊,不採納。" + }, + { + "location": "app/logger.js:53", + "role": "Assassin", + "original_finding": "failError 直接輸出 error.stack 可能洩漏專案目錄結構、內部函式名稱等敏感路徑;建議生產環境隱藏堆疊或僅 debug 模式輸出。", + "reason": "不適用。本專案為 CI/CD 清理 Action,堆疊輸出至僅儲存庫維護者可見的 Action 日誌以利除錯,非公開環境;容器內路徑為 /app,不含敏感資訊。隱藏堆疊反而降低可維運性。" + }, + { + "location": "app/gitea-client.js:10", + "role": "Leo", + "original_finding": "MAX_PAGES 寫死於程式碼,建議改為環境變數傳入以增加部署彈性。", + "reason": "不採納。MAX_PAGES 為防止無限迴圈的安全上限(backstop),非調校參數;1000 頁遠超任何真實儲存庫的 release/tag 規模,改為環境變數只會增加不必要的設定面與誤設風險。" + }, + { + "location": "app/logger.js:46", + "role": "Leo", + "original_finding": "建議在 logger.js 建立通用 log(level, prefix, stream) 函式,讓 info/warn/fail 等共用以降低重複。", + "reason": "不採納。六個輸出函式皆為單行、語意直觀;為此抽象通用 dispatcher 反而增加間接層與閱讀成本,去重效益微小。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 7c766ec..6f4a410 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,28 +1,4 @@ [ - { - "level": "critical", - "role": "Maya", - "problem": "測試只驗證了正常與極端的 token 輸入,但完全沒有測試 token 為 null 或未定義(匿名模式)下的處理邏輯,也沒確認匿名請求時,設定物件是否正確地將 token 設為 null。", - "suggestion": "請增加測試案例,明確斷言當 GITEA_TOKEN 不存在於環境變數時,loadConfig() 回傳的 token 屬性為 null。", - "location": "app/test/config.test.js:63", - "is_new": false - }, - { - "level": "critical", - "role": "Maya", - "problem": "測試中雖然有 fetchAllPages 的功能測試,但對於 MAX_PAGES 的邊界情況,測試只用了 globalThis.fetch 永遠回傳非空陣列,這是一個快樂路徑的極端變體。如果 API 剛好在第 MAX_PAGES 頁回傳空陣列,測試並未驗證客戶端是否能正確處理並停止。", - "suggestion": "請增加一個測試案例,模擬當 API 恰好在第 MAX_PAGES 次請求時回傳空陣列的情境,確認客戶端能成功結束,而非拋出例外。", - "location": "app/test/gitea-client.test.js:33", - "is_new": false - }, - { - "level": "critical", - "role": "Assassin", - "location": "app/config.js:35", - "problem": "僅驗證是否為 URL 格式,未檢查傳入的 `GITEA_SERVER_URL` 是否指向內部網路敏感資源(如 localhost, 169.254.169.254 等),這在容器化環境中可能導致 SSRF(伺服器端請求偽造)。", - "suggestion": "在 `validate.js` 的 `requireUrl` 中加入黑名單機制,禁止解析為內部 IP 位址或 loopback 位址。", - "is_new": true - }, { "level": "warning", "role": "Mage", @@ -30,8 +6,7 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", - "is_new": false + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" }, { "level": "warning", @@ -40,8 +15,7 @@ "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則。", "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑/編碼/stderr 測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。", - "is_new": false + "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑/編碼/stderr/決定性排序測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" }, { "level": "warning", @@ -50,8 +24,7 @@ "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,需伺服器被入侵或中間人攻擊才成立,且每請求已有 30 秒逾時部分約束。正確修法需串流逐段讀取並設位元組上限(Content-Length 在 chunked 下不可靠),屬較大改動,保留待人工評估。", - "is_new": false + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,且每請求已有 30 秒逾時。正確修法需串流逐段讀取並設位元組上限,屬較大改動,保留待人工評估。" }, { "level": "warning", @@ -60,93 +33,33 @@ "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。", - "is_new": false - }, - { - "level": "warning", - "role": "Maya", - "problem": "在 `cleanupReleases` 的整合測試中,雖然有測試 `deleteResource` 回傳非 204 時的行為,但測試只檢查了 `client.deleted` 的呼叫順序,並沒有驗證 `fail` 日誌(對 stderr 的寫入)是否正確被觸發。", - "suggestion": "建議攔截 `process.stderr.write`,確認當 `deleteResource` 回傳 500 時,系統確實有記錄到錯誤訊息。", - "location": "app/test/releases.test.js:77", - "is_new": false - }, - { - "level": "warning", - "role": "Maya", - "problem": "在 `cleanupOrphanTags` 的測試中,缺乏對於「當 `deleteResource` 失敗」時的行為驗證。目前只測試了成功的情境。", - "suggestion": "增加模擬 `deleteResource` 回傳非 204 狀態碼的情境,確認該項刪除失敗不會中斷整個標籤清理流程,並檢查是否有對應的錯誤日誌。", - "location": "app/test/tags.test.js:73", - "is_new": false + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" }, { "level": "warning", "role": "Assassin", "location": "app/config.js:34", - "problem": "將環境變數值直接輸出至日誌,若 `GITEA_SERVER_URL` 等變數內容被注入惡意字元或過長,可能造成 Log Injection 或日誌系統資源耗盡。", - "suggestion": "在輸出前進行 sanitization,移除控制字元並限制長度,類似於 `gitea-client.js` 中的 `sanitizeBody`。", - "is_new": true + "problem": "將環境變數值直接輸出至日誌,若內容含惡意字元或過長,可能造成 Log Injection 或日誌資源耗盡。", + "suggestion": "輸出前進行 sanitization,移除控制字元並限制長度,類似 gitea-client.js 的 sanitizeBody。", + "status": "deferred", + "defer_reason": "風險低:GITEA_SERVER_URL/GITEA_REPOSITORY/KEEP_COUNT 來自 CI 平台提供的 gitea context,並由 requireUrl/requireRepository/requireInteger 限定為不含控制字元的格式;log 注入需攻擊者控制 CI context 值才成立。可日後將記錄移至驗證後或加 sanitize,保留待人工評估。" }, { "level": "warning", "role": "Assassin", "location": "app/gitea-client.js:106", - "problem": "將 `items` 直接放入 `all` 陣列。若 API 返回異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。(註: 亦包含 Bard 關於 for...of/push 的效能建議)", - "suggestion": "考慮在 `fetchAllPages` 中增加最大總項目數量的限制,並在超過時拋出錯誤;同時建議使用 `AsyncGenerator` 進行串流式處理以降低記憶體佔用。" - }, - { - "level": "warning", - "role": "Bard", - "location": "app/releases.js:46", - "problem": "區段標題『取得成品資訊』與後續的 `section('刪除舊版本成品')` 使用了不同的區段層級。第一個區段內包含了 `info` 輸出,而第二個區段直接開始處理邏輯,視覺節奏上稍微不一致。", - "suggestion": "建議在所有主要的操作階段前統一呼叫 `section()`,或在細部操作前使用更明確的層級標示,保持日誌格式的旋律一致性。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:20", - "problem": "在 `selectReleasesToDelete` 函式中,對於 `Date.parse` 無法解析的日期直接視為 `0`,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。", - "suggestion": "應明確記錄無效日期的處理方式(例如記錄 warning),或在 `Date.parse` 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/releases.js:23", - "problem": "在 `selectReleasesToDelete` 的排序邏輯中,若多個成品具有完全相同的 `created_at` 時間戳,目前的排序行為依賴於 JavaScript 引擎對 `sort()` 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。", - "suggestion": "建議在排序邏輯中加入次要的排序鍵值(如 `id` 或 `tag_name`)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。", - "is_new": true + "problem": "將 items 直接放入 all 陣列,若 API 回傳異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。", + "suggestion": "增加最大總項目數限制,並考慮以 AsyncGenerator 串流式處理降低記憶體佔用。", + "status": "deferred", + "defer_reason": "總量已受 MAX_PAGES(1000 頁)間接約束;改為 AsyncGenerator 串流處理屬較大架構改動,對 release/tag 數量有限的清理任務效益不高,保留待人工評估。" }, { "level": "info", "role": "Bard", "location": "app/releases.js:28", - "problem": "config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。", - "suggestion": "建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。" - }, - { - "level": "info", - "role": "Assassin", - "location": "app/logger.js:53", - "problem": "儘管使用了 `stderr` 輸出錯誤,但在 `failError` 中直接輸出 `error.stack` 可能會洩漏專案目錄結構、內部函式名稱等敏感路徑資訊。", - "suggestion": "在生產環境下考慮隱藏堆疊追蹤,或僅在特定 debug 模式下輸出堆疊。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/gitea-client.js:10", - "problem": "目前 `MAX_PAGES` 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。", - "suggestion": "建議將 `MAX_PAGES` 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/logger.js:46", - "problem": "雖然目前只有 `fail` 函式會寫入 `stderr`,但如果有更多的 error level 需要處理,分散的邏輯會增加維護成本。", - "suggestion": "考慮在 `logger.js` 中建立一個通用的 `log` 函式,處理 `level`、`prefix` 與 `stream` 的對應,讓其他方法(如 `info`, `warn`, `fail`)只負責呼叫該通用函式,降低重複程式碼。", - "is_new": true + "problem": "config 參數型別 ReturnType 雖準確但冗長,影響可讀性。", + "suggestion": "在 config.js 定義並匯出 JSDoc @typedef,於各處改用該別名。", + "status": "deferred", + "defer_reason": "純可讀性偏好,現有型別註解準確且可運作;引入 @typedef 會牽動多處 JSDoc 與 README 行號,效益有限,保留待人工評估。" } ] -- 2.53.0 From 888edfea278d1fd137138adcc3a79ff4144beead Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:44:06 +0000 Subject: [PATCH 35/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 100 +++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 6f4a410..88660b0 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,4 +1,18 @@ [ + { + "level": "critical", + "role": "Assassin", + "location": "app/gitea-client.js:63", + "problem": "攻擊者可控制 baseUrl 參數,若未對 baseUrl 的來源進行嚴格限制,可能導致 Server-Side Request Forgery (SSRF) 攻擊。此處直接將變數拼接到 URL,如果 baseUrl 來自惡意環境變數,攻擊者能發送請求到內網資源或意圖控制的位址。", + "suggestion": "應在 config.js 中對 GITEA_SERVER_URL 和 GITEA_REPOSITORY 進行嚴格的 URL 結構驗證與白名單過濾,確保拼接出的 URL 處於預期範圍內。目前雖有 requireUrl 和 requireRepository,應進一步強化確保 baseUrl 只能以預期的 GITEA_SERVER_URL 開頭。" + }, + { + "level": "critical", + "role": "Maya", + "location": "app/index.js:21", + "problem": "Action 的核心邏輯 cleanupReleases 與 cleanupOrphanTags 被直接呼叫,但缺少針對清理流程的整合測試或端對端測試,僅有單元測試無法保證整個「清理 -> 再清理 tag」的完整路徑是否會因環境設定或 API 回應產生非預期的行為。", + "suggestion": "建議補上一個整合測試 (app/test/integration.test.js),模擬完整的 API 回應序列(如:先列出舊版本 -> 刪除舊版本 -> 重新列出 release -> 列出 tag -> 刪除孤立 tag),驗證所有 API 呼叫順序與參數皆符合預期。" + }, { "level": "warning", "role": "Mage", @@ -6,16 +20,8 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:37", - "problem": "cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則。", - "suggestion": "拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。", - "status": "deferred", - "defer_reason": "純邏輯(selectReleasesToDelete)已抽離且函式短小、已具失敗路徑/編碼/stderr/決定性排序測試;進一步拆出刪除迴圈為設計偏好,效益有限,保留待人工評估。" + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -24,7 +30,8 @@ "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,且每請求已有 30 秒逾時。正確修法需串流逐段讀取並設位元組上限,屬較大改動,保留待人工評估。" + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,且每請求已有 30 秒逾時。正確修法需串流逐段讀取並設位元組上限,屬較大改動,保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -33,33 +40,64 @@ "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/config.js:34", - "problem": "將環境變數值直接輸出至日誌,若內容含惡意字元或過長,可能造成 Log Injection 或日誌資源耗盡。", - "suggestion": "輸出前進行 sanitization,移除控制字元並限制長度,類似 gitea-client.js 的 sanitizeBody。", - "status": "deferred", - "defer_reason": "風險低:GITEA_SERVER_URL/GITEA_REPOSITORY/KEEP_COUNT 來自 CI 平台提供的 gitea context,並由 requireUrl/requireRepository/requireInteger 限定為不含控制字元的格式;log 注入需攻擊者控制 CI context 值才成立。可日後將記錄移至驗證後或加 sanitize,保留待人工評估。" + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。", + "is_new": false }, { "level": "warning", "role": "Assassin", "location": "app/gitea-client.js:106", - "problem": "將 items 直接放入 all 陣列,若 API 回傳異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。", - "suggestion": "增加最大總項目數限制,並考慮以 AsyncGenerator 串流式處理降低記憶體佔用。", - "status": "deferred", - "defer_reason": "總量已受 MAX_PAGES(1000 頁)間接約束;改為 AsyncGenerator 串流處理屬較大架構改動,對 release/tag 數量有限的清理任務效益不高,保留待人工評估。" + "problem": "將 items 直接放入 all 陣列。若 API 返回異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。(註: 亦包含 Bard 關於 for...of/push 的效能建議)", + "suggestion": "考慮在 fetchAllPages 中增加最大總項目數量的限制,並在超過時拋出錯誤;同時建議使用 AsyncGenerator 進行串流式處理以降低記憶體佔用。" + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/releases.js:64", + "problem": "雖然有 encodeURIComponent,但直接將 id 拼接至 URL 是危險操作。若 id 未經妥善驗證,攻擊者可能試圖透過特殊字元擾亂 API 路徑。", + "suggestion": "建議確保 id 在進入此函數前,已驗證為預期的整數類型或符合嚴格格式的字串,不要完全依賴 encodeURIComponent 來防範所有注入可能。" + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/tags.js:62", + "problem": "直接將 tag 名稱拼接至 URL 是常見的 Injection 破口。儘管使用了 encodeURIComponent,但若 tag.name 包含某些在 Gitea API 邏輯中具特殊意義的字元,仍可能造成非預期的資源存取或路徑穿越。", + "suggestion": "除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。" + }, + { + "level": "warning", + "role": "Bard", + "location": "app/tags.js:19", + "problem": "函式 `categorizeTags` 內部的判斷邏輯稍微複雜,且直接在 map 內部處理多種條件分支。", + "suggestion": "建議將邏輯拆分為更小的判斷函式,提高可讀性。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/tags.js:46", + "problem": "在 cleanupOrphanTags 中,呼叫了兩次 API 分別獲取 releases 和 tags。若 API 在這兩次呼叫間發生變更(例如新的 release 剛好把某個舊 tag 關聯起來),categorizeTags 的結果可能會基於不一致的狀態,造成誤刪。", + "suggestion": "這是一個典型的併發/時序問題。雖然 API 本身無交易機制,但建議在取得 releases 後,若 tags 數量巨大,應考慮是否存在原子性操作的需求,或者至少在日誌中明確標示兩次獲取資料的時間間距,以便除錯。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/releases.js:46", + "problem": "在 cleanupReleases 中,若 client.fetchAllPages 拋出錯誤,流程會直接中斷且 cleanupOrphanTags 將不會被執行。雖然這符合嚴格的錯誤處理,但缺乏「部分失敗」後的清理與回報機制。", + "suggestion": "考慮在 cleanupReleases 中加入 try...catch,若僅為該步驟失敗,記錄錯誤後仍嘗試執行 cleanupOrphanTags 或明確標示整個 Action 處於部分清理狀態。至少應確保在清理失敗時,日誌能明確指出是哪一步驟導致中斷。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/gitea-client.js:115", + "problem": "deleteResource 僅檢查狀態碼,若刪除失敗(非 204),目前實作僅回傳狀態碼,呼叫方需要處理後續的邏輯。但在測試中,對於非 204 的處理顯得較為鬆散。", + "suggestion": "建議在 deleteResource 內部就針對常見的錯誤狀態碼(如 401/403/404)進行特定的錯誤處理或拋出更有意義的例外,讓呼叫方能針對不同刪除失敗的原因採取對應策略(如:跳過、重試或完全中止)。" }, { "level": "info", - "role": "Bard", - "location": "app/releases.js:28", - "problem": "config 參數型別 ReturnType 雖準確但冗長,影響可讀性。", - "suggestion": "在 config.js 定義並匯出 JSDoc @typedef,於各處改用該別名。", - "status": "deferred", - "defer_reason": "純可讀性偏好,現有型別註解準確且可運作;引入 @typedef 會牽動多處 JSDoc 與 README 行號,效益有限,保留待人工評估。" + "role": "Maya", + "location": "app/config.js:34", + "problem": "雖然有 requireUrl 驗證,但在 loadConfig 中,對於 GITEA_SERVER_URL 的解析假設其為完整路徑。若環境變數提供的網址不含 api/v1 或路徑有變化,整合後的 releaseApiUrl 可能無效。", + "suggestion": "建議在 loadConfig 中增加對 serverUrl 的處理,確保結尾沒有多餘的 /,避免拼接 API 路徑時產生類似 //api 的錯誤路徑。" } ] -- 2.53.0 From 353a6af7c32789dfb259b0afdbc840b77a5a0135 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:55:48 +0800 Subject: [PATCH 36/43] =?UTF-8?q?fix(release-cleanup):=20=E5=8E=BB?= =?UTF-8?q?=E9=99=A4=20server=20URL=20=E7=B5=90=E5=B0=BE=E6=96=9C=E7=B7=9A?= =?UTF-8?q?=E4=B8=A6=E9=A9=97=E8=AD=89=20release=20id=20=E7=82=BA=E6=AD=A3?= =?UTF-8?q?=E6=95=B4=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadConfig 去除 GITEA_SERVER_URL 結尾的 /,避免拼出 //api/v1 錯誤路徑 - cleanupReleases 要求 id 為正整數,非整數一律略過(縱深防禦,不僅依賴編碼) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config.js | 5 ++++- app/releases.js | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/config.js b/app/config.js index f2895cc..49665be 100644 --- a/app/config.js +++ b/app/config.js @@ -26,7 +26,10 @@ import { section, info, warn } from './logger.js' export function loadConfig(env = process.env) { section('參數檢查') - const serverUrl = env.GITEA_SERVER_URL + // 去除結尾多餘的 /,避免後續拼接出 `https://host//api/v1/...` 這類錯誤路徑。 + const rawServerUrl = env.GITEA_SERVER_URL + const serverUrl = + typeof rawServerUrl === 'string' ? rawServerUrl.replace(/\/+$/, '') : rawServerUrl const repository = env.GITEA_REPOSITORY const keepCountRaw = env.KEEP_COUNT const token = env.GITEA_TOKEN diff --git a/app/releases.js b/app/releases.js index 2c65042..bb373a4 100644 --- a/app/releases.js +++ b/app/releases.js @@ -60,12 +60,13 @@ export async function cleanupReleases(client, config) { for (const release of toDelete) { const { id, tag_name: tag, name } = release - if (isEmptyOrNull(id)) { - warn(`略過沒有 id 的成品: ${tag} (${name})`) + // 要求 id 為正整數(Gitea release id 本即正整數);非整數一律略過,不僅依賴 URL 編碼防護。 + if (isEmptyOrNull(id) || !Number.isInteger(Number(id)) || Number(id) <= 0) { + warn(`略過沒有有效 id 的成品: ${tag} (${name})`) continue } - // 對 id 做編碼,避免非預期內容被拼接進 URL(防路徑穿越);正常數值 id 編碼後不變。 + // id 已驗證為正整數;仍對其編碼作為縱深防禦。 const url = `${config.releaseApiUrl}/${encodeURIComponent(id)}` info(`DELETE ${tag} (${name})`) -- 2.53.0 From edc933be73516ee93649936ec9756bdee7b93f39 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:55:48 +0800 Subject: [PATCH 37/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=E6=95=B4=E5=90=88=E6=B8=AC=E8=A9=A6=E3=80=81server=20?= =?UTF-8?q?URL=20=E6=AD=A3=E8=A6=8F=E5=8C=96=E8=88=87=20id=20=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增端對端整合測試(列出→刪除舊 release→重列→列 tag→刪孤立 tag 的呼叫順序)、 loadConfig 去尾斜線測試,並將 id 編碼測試改為驗證非整數 id 被略過。測試共 62 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/config.test.js | 9 +++++ app/test/integration.test.js | 70 ++++++++++++++++++++++++++++++++++++ app/test/releases.test.js | 6 ++-- 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 app/test/integration.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js index 4cea9e7..2444b2b 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -52,6 +52,15 @@ test('loadConfig 在有效輸入時回傳完整設定物件', () => { ) }) +test('loadConfig 去除 GITEA_SERVER_URL 結尾斜線,避免 API 路徑出現雙斜線', () => { + const cfg = loadConfig({ ...base, GITEA_SERVER_URL: 'https://gitea.example.com/' }) + assert.equal(cfg.serverUrl, 'https://gitea.example.com') + assert.equal( + cfg.releaseApiUrl, + 'https://gitea.example.com/api/v1/repos/owner/repo/releases', + ) +}) + test('loadConfig 在提供 token 時保留 token 值', () => { const cfg = loadConfig({ ...base, GITEA_TOKEN: 'secret' }) assert.equal(cfg.token, 'secret') diff --git a/app/test/integration.test.js b/app/test/integration.test.js new file mode 100644 index 0000000..d3941bb --- /dev/null +++ b/app/test/integration.test.js @@ -0,0 +1,70 @@ +import { test, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { main } from '../index.js' + +const realFetch = globalThis.fetch +const savedEnv = { ...process.env } + +afterEach(() => { + globalThis.fetch = realFetch + process.env = { ...savedEnv } +}) + +function jsonResponse(data) { + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => data, + text: async () => JSON.stringify(data), + } +} + +// 端對端驗證:列出 release → 刪除舊版本 → 重新列出 release → 列出 tag → 刪除孤立 tag。 +test('main 完整清理流程:刪除舊 release 與孤立 tag,呼叫順序符合預期', async () => { + for (const k of ['GITEA_SERVER_URL', 'GITEA_REPOSITORY', 'KEEP_COUNT', 'GITEA_TOKEN']) { + delete process.env[k] + } + Object.assign(process.env, { + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + KEEP_COUNT: '2', + }) + + const releases = [ + { id: 3, tag_name: 'v3', name: 'r3', created_at: '2024-03-01T00:00:00Z' }, + { id: 2, tag_name: 'v2', name: 'r2', created_at: '2024-02-01T00:00:00Z' }, + { id: 1, tag_name: 'v1', name: 'r1', created_at: '2024-01-01T00:00:00Z' }, + ] + const tags = [{ name: 'v3' }, { name: 'v2' }, { name: 'v1' }, { name: 'orphan' }] + + const calls = { get: [], delete: [] } + globalThis.fetch = async (url, opts) => { + const u = new URL(url) + const method = (opts && opts.method) || 'GET' + if (method === 'DELETE') { + calls.delete.push(u.pathname) + return { status: 204 } + } + const page = u.searchParams.get('page') + calls.get.push(`${u.pathname}?page=${page}`) + if (u.pathname.endsWith('/releases')) { + return jsonResponse(page === '1' ? releases : []) + } + if (u.pathname.endsWith('/tags')) { + return jsonResponse(page === '1' ? tags : []) + } + return jsonResponse([]) + } + + await main() + + // 保留最新 2 筆(v3、v2),刪除最舊 v1(id=1);孤立 tag 'orphan' 被刪除 + assert.deepEqual(calls.delete, [ + '/api/v1/repos/owner/repo/releases/1', + '/api/v1/repos/owner/repo/tags/orphan', + ]) + // release 清單被讀取兩次(清理舊版本一次、清理 tag 前重新讀取一次) + const releaseListGets = calls.get.filter((c) => c.includes('/releases?page=1')) + assert.equal(releaseListGets.length, 2) +}) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index a55964b..0091e8c 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -108,14 +108,14 @@ test('cleanupReleases 在 deleteResource 回傳非 204 時仍繼續處理其餘 assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1']) }) -test('cleanupReleases 對 id 進行 URL 編碼(防路徑穿越)', async () => { +test('cleanupReleases 略過非整數(惡意)id,不發出刪除請求', async () => { const client = fakeClient([ { id: 1, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, { id: '../evil', tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, ]) await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) - // 保留最新(id=1),刪除較舊者(惡意 id);id 中的 ../ 應被編碼 - assert.deepEqual(client.deleted, ['http://x/releases/..%2Fevil']) + // 保留最新(id=1);待刪除者 id 非整數 → 被略過,不會送出任何刪除 + assert.deepEqual(client.deleted, []) }) test('cleanupReleases 在刪除回傳非 204 時記錄錯誤至 stderr', async () => { -- 2.53.0 From 7b742ed85e2afedc8d0ca5ee0a5b0ffdebb3509f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:55:48 +0800 Subject: [PATCH 38/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除本輪已修復(server URL 正規化、id 整數驗證、整合測試)與重複提報的 finding; 保留 7 條設計取捨(重試/錯誤處理/DoS/TOCTOU/deleteResource 策略); 新增 3 條不適用至 exclusions(baseUrl 由已驗證 config 組成、tag 白名單非必要、 categorizeTags 拆分無益)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 18 +++++++ .gitea/ai-review/findings.json | 86 +++++++++----------------------- 2 files changed, 42 insertions(+), 62 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index ae4eff0..4607f96 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -106,5 +106,23 @@ "role": "Leo", "original_finding": "建議在 logger.js 建立通用 log(level, prefix, stream) 函式,讓 info/warn/fail 等共用以降低重複。", "reason": "不採納。六個輸出函式皆為單行、語意直觀;為此抽象通用 dispatcher 反而增加間接層與閱讀成本,去重效益微小。" + }, + { + "location": "app/gitea-client.js:63", + "role": "Assassin", + "original_finding": "baseUrl 直接拼接到 URL,若來自惡意環境變數恐 SSRF;建議確保 baseUrl 只能以預期的 GITEA_SERVER_URL 開頭。", + "reason": "不適用。fetchAllPages 的 baseUrl 並非外部輸入,而是由 config 的 releaseApiUrl/tagApiUrl 提供,二者皆以已通過 requireUrl 驗證的 GITEA_SERVER_URL 與通過 requireRepository 驗證的 repository 組成 `${serverUrl}/api/v1/repos/${repository}/...`,本即保證以 GITEA_SERVER_URL 開頭,無額外白名單必要。" + }, + { + "location": "app/tags.js:62", + "role": "Assassin", + "original_finding": "tag 名稱拼接至 URL 雖用 encodeURIComponent,仍建議以白名單格式驗證(限英數字、點、破折號,禁 .. 或 /)以更安全。", + "reason": "不採納。encodeURIComponent 會將路徑分隔字元 / 編碼為 %2F,已消除 URL 路徑穿越;且 git ref 命名規則本即禁止 tag 含 '..'、控制字元與多數特殊字元。額外白名單反而可能誤拒合法 tag 名,且非 URL 安全所必需。" + }, + { + "location": "app/tags.js:19", + "role": "Bard", + "original_finding": "categorizeTags 內部判斷邏輯稍複雜,建議拆分為更小的判斷函式以提高可讀性。", + "reason": "不採納。categorizeTags 僅為 skip/keep/delete 三分支的單層 map,語意已清楚且具完整測試;為三個簡單條件再抽出微函式只會增加跳轉與閱讀成本,可讀性無實質提升。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 88660b0..c781aa1 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,18 +1,4 @@ [ - { - "level": "critical", - "role": "Assassin", - "location": "app/gitea-client.js:63", - "problem": "攻擊者可控制 baseUrl 參數,若未對 baseUrl 的來源進行嚴格限制,可能導致 Server-Side Request Forgery (SSRF) 攻擊。此處直接將變數拼接到 URL,如果 baseUrl 來自惡意環境變數,攻擊者能發送請求到內網資源或意圖控制的位址。", - "suggestion": "應在 config.js 中對 GITEA_SERVER_URL 和 GITEA_REPOSITORY 進行嚴格的 URL 結構驗證與白名單過濾,確保拼接出的 URL 處於預期範圍內。目前雖有 requireUrl 和 requireRepository,應進一步強化確保 baseUrl 只能以預期的 GITEA_SERVER_URL 開頭。" - }, - { - "level": "critical", - "role": "Maya", - "location": "app/index.js:21", - "problem": "Action 的核心邏輯 cleanupReleases 與 cleanupOrphanTags 被直接呼叫,但缺少針對清理流程的整合測試或端對端測試,僅有單元測試無法保證整個「清理 -> 再清理 tag」的完整路徑是否會因環境設定或 API 回應產生非預期的行為。", - "suggestion": "建議補上一個整合測試 (app/test/integration.test.js),模擬完整的 API 回應序列(如:先列出舊版本 -> 刪除舊版本 -> 重新列出 release -> 列出 tag -> 刪除孤立 tag),驗證所有 API 呼叫順序與參數皆符合預期。" - }, { "level": "warning", "role": "Mage", @@ -20,8 +6,16 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", - "is_new": false + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/releases.js:46", + "problem": "cleanupReleases 中若 fetchAllPages 拋錯,cleanupOrphanTags 不會執行;缺乏部分失敗後的清理與回報機制。", + "suggestion": "考慮在 cleanupReleases 加入 try/catch,僅該步驟失敗時記錄並仍嘗試 cleanupOrphanTags,或明確標示部分清理狀態。", + "status": "deferred", + "defer_reason": "目前採 fail-fast:無法取得 release 清單時不應依過時資料刪除 tag,中止較安全;且 fetchAllPages 的錯誤訊息已含失敗的 GET URL,可辨識中斷步驟。是否改為跨步驟續行屬設計取捨,保留待人工評估。" }, { "level": "warning", @@ -30,8 +24,7 @@ "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,且每請求已有 30 秒逾時。正確修法需串流逐段讀取並設位元組上限,屬較大改動,保留待人工評估。", - "is_new": false + "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,且每請求已有 30 秒逾時。正確修法需串流逐段讀取並設位元組上限,屬較大改動,保留待人工評估。" }, { "level": "warning", @@ -40,64 +33,33 @@ "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。", - "is_new": false + "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" }, { "level": "warning", "role": "Assassin", "location": "app/gitea-client.js:106", - "problem": "將 items 直接放入 all 陣列。若 API 返回異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。(註: 亦包含 Bard 關於 for...of/push 的效能建議)", - "suggestion": "考慮在 fetchAllPages 中增加最大總項目數量的限制,並在超過時拋出錯誤;同時建議使用 AsyncGenerator 進行串流式處理以降低記憶體佔用。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/releases.js:64", - "problem": "雖然有 encodeURIComponent,但直接將 id 拼接至 URL 是危險操作。若 id 未經妥善驗證,攻擊者可能試圖透過特殊字元擾亂 API 路徑。", - "suggestion": "建議確保 id 在進入此函數前,已驗證為預期的整數類型或符合嚴格格式的字串,不要完全依賴 encodeURIComponent 來防範所有注入可能。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/tags.js:62", - "problem": "直接將 tag 名稱拼接至 URL 是常見的 Injection 破口。儘管使用了 encodeURIComponent,但若 tag.name 包含某些在 Gitea API 邏輯中具特殊意義的字元,仍可能造成非預期的資源存取或路徑穿越。", - "suggestion": "除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。" - }, - { - "level": "warning", - "role": "Bard", - "location": "app/tags.js:19", - "problem": "函式 `categorizeTags` 內部的判斷邏輯稍微複雜,且直接在 map 內部處理多種條件分支。", - "suggestion": "建議將邏輯拆分為更小的判斷函式,提高可讀性。", - "is_new": true + "problem": "將 items 直接放入 all 陣列,若 API 回傳異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。", + "suggestion": "增加最大總項目數限制,並考慮以 AsyncGenerator 串流式處理降低記憶體佔用。", + "status": "deferred", + "defer_reason": "總量已受 MAX_PAGES(1000 頁)間接約束;改為 AsyncGenerator 串流處理屬較大架構改動,對 release/tag 數量有限的清理任務效益不高,保留待人工評估。" }, { "level": "warning", "role": "Mage", "location": "app/tags.js:46", - "problem": "在 cleanupOrphanTags 中,呼叫了兩次 API 分別獲取 releases 和 tags。若 API 在這兩次呼叫間發生變更(例如新的 release 剛好把某個舊 tag 關聯起來),categorizeTags 的結果可能會基於不一致的狀態,造成誤刪。", - "suggestion": "這是一個典型的併發/時序問題。雖然 API 本身無交易機制,但建議在取得 releases 後,若 tags 數量巨大,應考慮是否存在原子性操作的需求,或者至少在日誌中明確標示兩次獲取資料的時間間距,以便除錯。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/releases.js:46", - "problem": "在 cleanupReleases 中,若 client.fetchAllPages 拋出錯誤,流程會直接中斷且 cleanupOrphanTags 將不會被執行。雖然這符合嚴格的錯誤處理,但缺乏「部分失敗」後的清理與回報機制。", - "suggestion": "考慮在 cleanupReleases 中加入 try...catch,若僅為該步驟失敗,記錄錯誤後仍嘗試執行 cleanupOrphanTags 或明確標示整個 Action 處於部分清理狀態。至少應確保在清理失敗時,日誌能明確指出是哪一步驟導致中斷。" + "problem": "cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,兩次之間若 Gitea 狀態變更,categorizeTags 可能基於不一致狀態而誤刪。", + "suggestion": "考量原子性需求,或至少在日誌標示兩次取得資料的時間間距以利除錯。", + "status": "deferred", + "defer_reason": "Gitea API 無交易機制;現行已在 cleanupOrphanTags 開頭重新抓取最新 release 作為主要緩解,殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本取捨,保留待人工評估。" }, { "level": "warning", "role": "Maya", "location": "app/gitea-client.js:115", - "problem": "deleteResource 僅檢查狀態碼,若刪除失敗(非 204),目前實作僅回傳狀態碼,呼叫方需要處理後續的邏輯。但在測試中,對於非 204 的處理顯得較為鬆散。", - "suggestion": "建議在 deleteResource 內部就針對常見的錯誤狀態碼(如 401/403/404)進行特定的錯誤處理或拋出更有意義的例外,讓呼叫方能針對不同刪除失敗的原因採取對應策略(如:跳過、重試或完全中止)。" - }, - { - "level": "info", - "role": "Maya", - "location": "app/config.js:34", - "problem": "雖然有 requireUrl 驗證,但在 loadConfig 中,對於 GITEA_SERVER_URL 的解析假設其為完整路徑。若環境變數提供的網址不含 api/v1 或路徑有變化,整合後的 releaseApiUrl 可能無效。", - "suggestion": "建議在 loadConfig 中增加對 serverUrl 的處理,確保結尾沒有多餘的 /,避免拼接 API 路徑時產生類似 //api 的錯誤路徑。" + "problem": "deleteResource 僅回傳狀態碼,對於 401/403/404 等錯誤未做區分處理,呼叫方策略較鬆散。", + "suggestion": "在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方可採取跳過/重試/中止等對應策略。", + "status": "deferred", + "defer_reason": "現行刻意讓 deleteResource 單純回傳狀態碼、由呼叫端記錄 HTTP code 後續行;依狀態碼採取不同策略(如遇 401 中止)與重試/閾值同屬錯誤處理設計取捨,與 app/releases.js:38 一併保留待人工評估。" } ] -- 2.53.0 From 1bc6ce6d8cf0598e9969184b7a42de2bef42c01e Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 05:56:45 +0000 Subject: [PATCH 39/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/exclusions.json | 12 +++++ .gitea/ai-review/findings.json | 85 ++++++++++++++++++-------------- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 4607f96..531891e 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -124,5 +124,17 @@ "role": "Bard", "original_finding": "categorizeTags 內部判斷邏輯稍複雜,建議拆分為更小的判斷函式以提高可讀性。", "reason": "不採納。categorizeTags 僅為 skip/keep/delete 三分支的單層 map,語意已清楚且具完整測試;為三個簡單條件再抽出微函式只會增加跳轉與閱讀成本,可讀性無實質提升。" + }, + { + "location": "app/tags.js:19", + "role": "Bard", + "original_finding": "建議將邏輯拆分為更小的判斷函式,提高可讀性。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "app/tags.js:62", + "role": "Assassin", + "original_finding": "除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index c781aa1..636265f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,4 +1,44 @@ [ + { + "level": "critical", + "role": "Maya", + "problem": "Action 的核心邏輯 cleanupReleases 與 cleanupOrphanTags 被直接呼叫,但缺少針對清理流程的整合測試或端對端測試,僅有單元測試無法保證整個「清理 -> 再清理 tag」的完整路徑是否會因環境設定或 API 回應產生非預期的行為。", + "suggestion": "建議補上一個整合測試 (app/test/integration.test.js),模擬完整的 API 回應序列(如:先列出舊版本 -> 刪除舊版本 -> 重新列出 release -> 列出 tag -> 刪除孤立 tag),驗證所有 API 呼叫順序與參數皆符合預期。", + "location": "app/index.js:21", + "is_new": false + }, + { + "level": "critical", + "role": "Mage", + "location": "app/config.js:33", + "problem": "在 loadConfig 函式中,對 GITEA_SERVER_URL 的 replace(//+$/, '') 操作假設了輸入一定是 string。雖然隨後有 requireValue 和 requireUrl 的驗證,但如果 env.GITEA_SERVER_URL 是非字串(例如數字、陣列、物件),這裡的 replace 可能會拋出 TypeError。", + "suggestion": "應在 replace 之前確保其型別為 string,或使用 optional chaining 以及更嚴格的類型防護。例如: `typeof rawServerUrl === 'string' ? rawServerUrl.replace(//+$/, '') : rawServerUrl` 已經做了檢查,但後續的 `requireValue` 檢查順序應確保傳入的是處理後的字串。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/config.js:63", + "problem": "在 `loadConfig` 中,`serverUrl`、`repository`、`token` 以及 `keepCount` 雖有呼叫驗證函式,但這些驗證函式(如 `requireValue`、`requireInteger`)在失敗時會直接拋出錯誤,且沒有相對應的單元測試去驗證這些設定錯誤的情境,尤其是當環境變數輸入惡意或無效字串時的系統行為。", + "suggestion": "補上針對 `config.js` 的完整錯誤處理測試,特別是模擬 `process.env` 各項缺失或異常時,確認是否確實會拋出預期的 Error,並確保清理流程在設定錯誤時能安全停止。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/releases.js:45", + "problem": "在 `cleanupReleases` 函數中,儘管對 `id` 進行了 `isEmptyOrNull` 檢查,但對於 `releases` 陣列中可能存在的 `null`、`undefined` 或非預期結構的 `release` 物件,測試覆蓋僅止於 `id` 為空的情況,缺少對 `release` 本身結構異常的測試案例。", + "suggestion": "增加 `cleanupReleases` 的測試案例,模擬傳入包含異常結構(例如缺少 `tag_name` 或其他必要欄位)的 `release` 物件,確保在處理過程中不會拋出未捕捉的異常。", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/tags.js:56", + "problem": "在 `cleanupOrphanTags` 中,雖有 `categorizeTags` 的邏輯測試,但對於 `cleanupOrphanTags` 本身與 `GiteaClient` 的整合互動,例如在 API 回傳異常 tag 列表時的處理,缺乏失敗路徑(如 API 請求失敗、JSON 解析失敗)的測試。", + "suggestion": "補上 `cleanupOrphanTags` 的整合測試,模擬 `client.fetchAllPages` 拋出例外的情境,確認清理流程是否會優雅地處理或拋出預期的錯誤。", + "is_new": true + }, { "level": "warning", "role": "Mage", @@ -6,43 +46,15 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", + "is_new": false }, { "level": "warning", "role": "Maya", "location": "app/releases.js:46", - "problem": "cleanupReleases 中若 fetchAllPages 拋錯,cleanupOrphanTags 不會執行;缺乏部分失敗後的清理與回報機制。", - "suggestion": "考慮在 cleanupReleases 加入 try/catch,僅該步驟失敗時記錄並仍嘗試 cleanupOrphanTags,或明確標示部分清理狀態。", - "status": "deferred", - "defer_reason": "目前採 fail-fast:無法取得 release 清單時不應依過時資料刪除 tag,中止較安全;且 fetchAllPages 的錯誤訊息已含失敗的 GET URL,可辨識中斷步驟。是否改為跨步驟續行屬設計取捨,保留待人工評估。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/gitea-client.js:66", - "problem": "錯誤路徑以 res.text() 讀取整個回應主體,未限制大小,惡意伺服器可回傳極大內容導致記憶體耗盡(DoS)。", - "suggestion": "限制讀取的回應大小,例如檢查 Content-Length 或以串流方式設定讀取上限。", - "status": "deferred", - "defer_reason": "風險低:目標為已通過 URL 驗證的受信任 Gitea 實例,且每請求已有 30 秒逾時。正確修法需串流逐段讀取並設位元組上限,屬較大改動,保留待人工評估。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/gitea-client.js:77", - "problem": "成功路徑以 res.json() 解析整個回應,未限制大小,惡意伺服器可回傳極大 JSON 導致記憶體耗盡(DoS)。", - "suggestion": "對 API 回應設定明確大小上限,超過時拒絕解析並拋出異常。", - "status": "deferred", - "defer_reason": "與 app/gitea-client.js:66 同類:受信任目標、已有逾時與 MAX_PAGES 約束,風險低;正確修法需串流讀取並設上限,屬較大改動,保留待人工評估。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/gitea-client.js:106", - "problem": "將 items 直接放入 all 陣列,若 API 回傳異常巨大的 JSON 陣列,可能導致容器記憶體耗盡(DoS)。", - "suggestion": "增加最大總項目數限制,並考慮以 AsyncGenerator 串流式處理降低記憶體佔用。", - "status": "deferred", - "defer_reason": "總量已受 MAX_PAGES(1000 頁)間接約束;改為 AsyncGenerator 串流處理屬較大架構改動,對 release/tag 數量有限的清理任務效益不高,保留待人工評估。" + "problem": "cleanupReleases 中若 fetchAllPages 拋錯,cleanupOrphanTags 不會執行;缺乏部分失敗後的清理與回報機制。且當 fetchAllPages 成功取得列表後,若後續刪除操作發生異常(例如網路中斷、API 回應逾時),會拋出錯誤並導致整個 cleanupReleases 流程終止,無法保證「已讀取到的所有舊 release」都被嘗試清理。", + "suggestion": "考慮在 cleanupReleases 加入 try/catch,並將逐筆刪除的迴圈包覆在 try-catch 中,即使單步驟或單筆刪除失敗,也應記錄錯誤後繼續嘗試執行後續步驟或刪除下一筆,僅該步驟失敗時記錄並仍嘗試 cleanupOrphanTags,或明確標示部分清理狀態。" }, { "level": "warning", @@ -51,15 +63,14 @@ "problem": "cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,兩次之間若 Gitea 狀態變更,categorizeTags 可能基於不一致狀態而誤刪。", "suggestion": "考量原子性需求,或至少在日誌標示兩次取得資料的時間間距以利除錯。", "status": "deferred", - "defer_reason": "Gitea API 無交易機制;現行已在 cleanupOrphanTags 開頭重新抓取最新 release 作為主要緩解,殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本取捨,保留待人工評估。" + "defer_reason": "Gitea API 無交易機制;現行已在 cleanupOrphanTags 開頭重新抓取最新 release 作為主要緩解,殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本取捨,保留待人工評估。", + "is_new": false }, { "level": "warning", "role": "Maya", "location": "app/gitea-client.js:115", - "problem": "deleteResource 僅回傳狀態碼,對於 401/403/404 等錯誤未做區分處理,呼叫方策略較鬆散。", - "suggestion": "在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方可採取跳過/重試/中止等對應策略。", - "status": "deferred", - "defer_reason": "現行刻意讓 deleteResource 單純回傳狀態碼、由呼叫端記錄 HTTP code 後續行;依狀態碼採取不同策略(如遇 401 中止)與重試/閾值同屬錯誤處理設計取捨,與 app/releases.js:38 一併保留待人工評估。" + "problem": "deleteResource 僅回傳狀態碼,對於 401/403/404 等錯誤未做區分處理,呼叫方策略較鬆散,且對於非 204 的處理顯得較為鬆散。", + "suggestion": "在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方可採取跳過/重試/中止等對應策略。" } ] -- 2.53.0 From 24bcad714104dd78daad031fd3eaac0fef1abdda Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:59:49 +0800 Subject: [PATCH 40/43] =?UTF-8?q?fix(release-cleanup):=20cleanupReleases?= =?UTF-8?q?=20=E9=98=B2=E7=A6=A6=20null/=E7=95=B0=E5=B8=B8=E7=B5=90?= =?UTF-8?q?=E6=A7=8B=E7=9A=84=20release=20=E9=A0=85=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在刪除迴圈中先檢查項目為非 null 物件,避免對非預期結構解構時拋出未捕捉例外。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/releases.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/releases.js b/app/releases.js index bb373a4..b338a17 100644 --- a/app/releases.js +++ b/app/releases.js @@ -58,6 +58,12 @@ export async function cleanupReleases(client, config) { const toDelete = selectReleasesToDelete(releases, config.keepCount) for (const release of toDelete) { + // 防禦非預期結構(null/非物件),避免解構時拋出未捕捉例外。 + if (release === null || typeof release !== 'object') { + warn('略過格式異常的成品項目') + continue + } + const { id, tag_name: tag, name } = release // 要求 id 為正整數(Gitea release id 本即正整數);非整數一律略過,不僅依賴 URL 編碼防護。 -- 2.53.0 From 9a71e2d09d0a0f39d28a357eeaf85744b66c83f9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:59:49 +0800 Subject: [PATCH 41/43] =?UTF-8?q?test(release-cleanup):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=E7=95=B0=E5=B8=B8=E7=B5=90=E6=A7=8B=20release=20?= =?UTF-8?q?=E8=88=87=20fetchAllPages=20=E5=A4=B1=E6=95=97=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 cleanupReleases 處理 null/缺欄位 release 不拋例外的測試,以及 cleanupOrphanTags 在 fetchAllPages 拋錯時向外拋出的測試。測試共 64 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/releases.test.js | 14 ++++++++++++++ app/test/tags.test.js | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 0091e8c..06690d6 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -140,6 +140,20 @@ test('cleanupReleases 在刪除回傳非 204 時記錄錯誤至 stderr', async ( assert.match(chunks.join(''), /\[ERR\]/) }) +test('cleanupReleases 對含 null/異常結構的 release 不拋例外且照常處理正常項目', async () => { + const client = fakeClient([ + { id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, + null, + { created_at: '2024-02-01T00:00:00Z' }, // 缺 id/tag_name + { id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, + ]) + await assert.doesNotReject(() => + cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }), + ) + // 保留最新(id=3);待刪除者中僅 id=1 為有效正整數 id,其餘(null、缺 id)被略過 + assert.deepEqual(client.deleted, ['http://x/releases/1']) +}) + test('cleanupReleases 略過沒有 id 的 release', async () => { const client = fakeClient([ { id: 1, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, diff --git a/app/test/tags.test.js b/app/test/tags.test.js index 226c4f9..5e80792 100644 --- a/app/test/tags.test.js +++ b/app/test/tags.test.js @@ -114,6 +114,18 @@ test('cleanupOrphanTags 在刪除回傳非 204 時繼續處理其餘 tag 並記 assert.match(chunks.join(''), /\[ERR\]/) }) +test('cleanupOrphanTags 在 fetchAllPages 拋錯時向外拋出', async () => { + const client = { + async fetchAllPages() { + throw new Error('GET http://x/releases?page=1 failed: HTTP 500') + }, + async deleteResource() { + return 204 + }, + } + await assert.rejects(() => cleanupOrphanTags(client, config), /HTTP 500/) +}) + test('cleanupOrphanTags 對含特殊字元的 tag 名稱進行 URL 編碼(防路徑穿越)', async () => { const client = fakeClient({ releases: [], -- 2.53.0 From 9e598143bfa9542912c8a150cd39cec39b390537 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:59:49 +0800 Subject: [PATCH 42/43] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings.json=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除本輪已修復(防禦異常結構)與重複提報的 finding;保留 4 條錯誤處理類設計取捨; 將 config.js replace 型別 finding 登記為不成立(已有 typeof 防護)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 6 ++++ .gitea/ai-review/findings.json | 58 ++++++-------------------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 531891e..352bf3b 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -136,5 +136,11 @@ "role": "Assassin", "original_finding": "除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。", "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "app/config.js:33", + "role": "Mage", + "original_finding": "loadConfig 對 GITEA_SERVER_URL 的 replace 假設輸入為 string,非字串恐拋 TypeError。", + "reason": "不成立。該行已以 `typeof rawServerUrl === 'string' ? rawServerUrl.replace(...) : rawServerUrl` 做型別防護,非字串時不呼叫 replace;finding 內文亦自承「已經做了檢查」。env 變數本即為字串或 undefined,不會出現陣列/物件。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 636265f..16ccde0 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,44 +1,4 @@ [ - { - "level": "critical", - "role": "Maya", - "problem": "Action 的核心邏輯 cleanupReleases 與 cleanupOrphanTags 被直接呼叫,但缺少針對清理流程的整合測試或端對端測試,僅有單元測試無法保證整個「清理 -> 再清理 tag」的完整路徑是否會因環境設定或 API 回應產生非預期的行為。", - "suggestion": "建議補上一個整合測試 (app/test/integration.test.js),模擬完整的 API 回應序列(如:先列出舊版本 -> 刪除舊版本 -> 重新列出 release -> 列出 tag -> 刪除孤立 tag),驗證所有 API 呼叫順序與參數皆符合預期。", - "location": "app/index.js:21", - "is_new": false - }, - { - "level": "critical", - "role": "Mage", - "location": "app/config.js:33", - "problem": "在 loadConfig 函式中,對 GITEA_SERVER_URL 的 replace(//+$/, '') 操作假設了輸入一定是 string。雖然隨後有 requireValue 和 requireUrl 的驗證,但如果 env.GITEA_SERVER_URL 是非字串(例如數字、陣列、物件),這裡的 replace 可能會拋出 TypeError。", - "suggestion": "應在 replace 之前確保其型別為 string,或使用 optional chaining 以及更嚴格的類型防護。例如: `typeof rawServerUrl === 'string' ? rawServerUrl.replace(//+$/, '') : rawServerUrl` 已經做了檢查,但後續的 `requireValue` 檢查順序應確保傳入的是處理後的字串。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/config.js:63", - "problem": "在 `loadConfig` 中,`serverUrl`、`repository`、`token` 以及 `keepCount` 雖有呼叫驗證函式,但這些驗證函式(如 `requireValue`、`requireInteger`)在失敗時會直接拋出錯誤,且沒有相對應的單元測試去驗證這些設定錯誤的情境,尤其是當環境變數輸入惡意或無效字串時的系統行為。", - "suggestion": "補上針對 `config.js` 的完整錯誤處理測試,特別是模擬 `process.env` 各項缺失或異常時,確認是否確實會拋出預期的 Error,並確保清理流程在設定錯誤時能安全停止。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/releases.js:45", - "problem": "在 `cleanupReleases` 函數中,儘管對 `id` 進行了 `isEmptyOrNull` 檢查,但對於 `releases` 陣列中可能存在的 `null`、`undefined` 或非預期結構的 `release` 物件,測試覆蓋僅止於 `id` 為空的情況,缺少對 `release` 本身結構異常的測試案例。", - "suggestion": "增加 `cleanupReleases` 的測試案例,模擬傳入包含異常結構(例如缺少 `tag_name` 或其他必要欄位)的 `release` 物件,確保在處理過程中不會拋出未捕捉的異常。", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/tags.js:56", - "problem": "在 `cleanupOrphanTags` 中,雖有 `categorizeTags` 的邏輯測試,但對於 `cleanupOrphanTags` 本身與 `GiteaClient` 的整合互動,例如在 API 回傳異常 tag 列表時的處理,缺乏失敗路徑(如 API 請求失敗、JSON 解析失敗)的測試。", - "suggestion": "補上 `cleanupOrphanTags` 的整合測試,模擬 `client.fetchAllPages` 拋出例外的情境,確認清理流程是否會優雅地處理或拋出預期的錯誤。", - "is_new": true - }, { "level": "warning", "role": "Mage", @@ -46,15 +6,16 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", - "is_new": false + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗(非 204)會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" }, { "level": "warning", "role": "Maya", "location": "app/releases.js:46", - "problem": "cleanupReleases 中若 fetchAllPages 拋錯,cleanupOrphanTags 不會執行;缺乏部分失敗後的清理與回報機制。且當 fetchAllPages 成功取得列表後,若後續刪除操作發生異常(例如網路中斷、API 回應逾時),會拋出錯誤並導致整個 cleanupReleases 流程終止,無法保證「已讀取到的所有舊 release」都被嘗試清理。", - "suggestion": "考慮在 cleanupReleases 加入 try/catch,並將逐筆刪除的迴圈包覆在 try-catch 中,即使單步驟或單筆刪除失敗,也應記錄錯誤後繼續嘗試執行後續步驟或刪除下一筆,僅該步驟失敗時記錄並仍嘗試 cleanupOrphanTags,或明確標示部分清理狀態。" + "problem": "cleanupReleases 中若刪除過程拋出例外(如網路中斷),會終止整個流程,無法保證已讀取的舊 release 都被嘗試清理。", + "suggestion": "將逐筆刪除包在 try/catch,單筆刪除拋例外時記錄後續刪下一筆;或明確標示部分清理狀態。", + "status": "deferred", + "defer_reason": "同屬錯誤處理策略的設計取捨(與 releases.js:38)。目前 deleteResource 對 HTTP 失敗回傳狀態碼、迴圈已續行;僅網路層例外會中止,屬 fail-fast 保守行為。是否改為逐筆吞例外續行保留待人工評估(清理為冪等,下次排程會補做)。" }, { "level": "warning", @@ -63,14 +24,15 @@ "problem": "cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,兩次之間若 Gitea 狀態變更,categorizeTags 可能基於不一致狀態而誤刪。", "suggestion": "考量原子性需求,或至少在日誌標示兩次取得資料的時間間距以利除錯。", "status": "deferred", - "defer_reason": "Gitea API 無交易機制;現行已在 cleanupOrphanTags 開頭重新抓取最新 release 作為主要緩解,殘餘競態窗極小且屬排程任務可接受範圍。是否再加每筆刪除前複查屬一致性/成本取捨,保留待人工評估。", - "is_new": false + "defer_reason": "Gitea API 無交易機制;現行已在 cleanupOrphanTags 開頭重新抓取最新 release 作為主要緩解,殘餘競態窗極小且屬排程任務可接受範圍,保留待人工評估。" }, { "level": "warning", "role": "Maya", "location": "app/gitea-client.js:115", - "problem": "deleteResource 僅回傳狀態碼,對於 401/403/404 等錯誤未做區分處理,呼叫方策略較鬆散,且對於非 204 的處理顯得較為鬆散。", - "suggestion": "在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方可採取跳過/重試/中止等對應策略。" + "problem": "deleteResource 僅回傳狀態碼,對於 401/403/404 等錯誤未做區分處理。", + "suggestion": "在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方可採取跳過/重試/中止等策略。", + "status": "deferred", + "defer_reason": "現行刻意讓 deleteResource 單純回傳狀態碼、由呼叫端記錄 HTTP code 後續行;依狀態碼採取不同策略與重試/閾值同屬錯誤處理設計取捨,與 releases.js:38 一併保留待人工評估。" } ] -- 2.53.0 From e6bd11a019b143d258cb066926bc0e61175b8b74 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:00:47 +0000 Subject: [PATCH 43/43] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 49 +++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 16ccde0..e9450a2 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,4 +1,19 @@ [ + { + "level": "critical", + "role": "Mage", + "location": "app/tags.js:52", + "problem": "在 `cleanupOrphanTags` 中,分兩次 API 呼叫取得 releases 與 tags,且過濾過程未保證原子性。若期間 Gitea 狀態變更,可能導致誤刪並非孤立的 tag。", + "suggestion": "考量原子性需求,或在刪除操作前增加確認機制;並明確文件化此風險。" + }, + { + "level": "critical", + "role": "Maya", + "problem": "在 `loadConfig` 中,`serverUrl`、`repository`、`token` 以及 `keepCount` 雖有呼叫驗證函式,但這些驗證函式(如 `requireValue`、`requireInteger`)在失敗時會直接拋出錯誤,且沒有相對應的單元測試去驗證這些設定錯誤的情境,尤其是當環境變數輸入惡意或無效字串時的系統行為。", + "suggestion": "補上針對 `config.js` 的完整錯誤處理測試,特別是模擬 `process.env` 各項缺失或異常時,確認是否確實會拋出預期的 Error,並確保清理流程在設定錯誤時能安全停止。", + "location": "app/config.js:63", + "is_new": false + }, { "level": "warning", "role": "Mage", @@ -6,7 +21,8 @@ "problem": "清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。", "suggestion": "引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。", "status": "deferred", - "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗(非 204)會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。" + "defer_reason": "錯誤處理/重試策略的設計取捨。目前單筆刪除失敗(非 204)會記錄並繼續、讀取失敗則中止屬合理保守行為;是否引入閾值/重試保留待人工評估。", + "is_new": false }, { "level": "warning", @@ -15,16 +31,8 @@ "problem": "cleanupReleases 中若刪除過程拋出例外(如網路中斷),會終止整個流程,無法保證已讀取的舊 release 都被嘗試清理。", "suggestion": "將逐筆刪除包在 try/catch,單筆刪除拋例外時記錄後續刪下一筆;或明確標示部分清理狀態。", "status": "deferred", - "defer_reason": "同屬錯誤處理策略的設計取捨(與 releases.js:38)。目前 deleteResource 對 HTTP 失敗回傳狀態碼、迴圈已續行;僅網路層例外會中止,屬 fail-fast 保守行為。是否改為逐筆吞例外續行保留待人工評估(清理為冪等,下次排程會補做)。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/tags.js:46", - "problem": "cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,兩次之間若 Gitea 狀態變更,categorizeTags 可能基於不一致狀態而誤刪。", - "suggestion": "考量原子性需求,或至少在日誌標示兩次取得資料的時間間距以利除錯。", - "status": "deferred", - "defer_reason": "Gitea API 無交易機制;現行已在 cleanupOrphanTags 開頭重新抓取最新 release 作為主要緩解,殘餘競態窗極小且屬排程任務可接受範圍,保留待人工評估。" + "defer_reason": "同屬錯誤處理策略的設計取捨(與 releases.js:38)。目前 deleteResource 對 HTTP 失敗回傳狀態碼、迴圈已續行;僅網路層例外會中止,屬 fail-fast 保守行為。是否改為逐筆吞例外續行保留待人工評估(清理為冪等,下次排程會補做)。", + "is_new": false }, { "level": "warning", @@ -33,6 +41,23 @@ "problem": "deleteResource 僅回傳狀態碼,對於 401/403/404 等錯誤未做區分處理。", "suggestion": "在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方可採取跳過/重試/中止等策略。", "status": "deferred", - "defer_reason": "現行刻意讓 deleteResource 單純回傳狀態碼、由呼叫端記錄 HTTP code 後續行;依狀態碼採取不同策略與重試/閾值同屬錯誤處理設計取捨,與 releases.js:38 一併保留待人工評估。" + "defer_reason": "現行刻意讓 deleteResource 單純回傳狀態碼、由呼叫端記錄 HTTP code 後續行;依狀態碼採取不同策略與重試/閾值同屬錯誤處理設計取捨,與 releases.js:38 一併保留待人工評估。", + "is_new": false + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:47", + "problem": "在 `cleanupReleases` 迴圈中,對於每一個 release 都呼叫 `await client.deleteResource(url)`。若 release 數量眾多,這種依序刪除的方式非常耗時,且若 API 負載過高,容易導致部分請求逾時。", + "suggestion": "如果 API 允許,建議採用併發刪除(例如使用 `Promise.all` 限制併發數),或者在日誌中增加處理進度,並考慮增加對 API 錯誤的重試機制。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/tags.js:54", + "problem": "在刪除 tag 的迴圈中,同樣缺少對 `deleteResource` 拋出例外的防禦,一旦發生網路錯誤,後續的 tag 將無法被清理。", + "suggestion": "在 `for` 迴圈內增加 `try...catch` 區塊,記錄錯誤並 `continue` 以確保後續 tag 能正常被刪除;應比照 `releases.js` 新增測試驗證此行為。", + "is_new": true } ] -- 2.53.0