From 9e88f5a9d5d953cb1df121c85bbad83ed10b5e0a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:28:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(release-cleanup):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E9=87=8D=E8=A9=A6+=E6=9C=89=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E4=BD=B5=E7=99=BC+best-effort=20=E7=BA=8C=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanupReleases/cleanupOrphanTags 改用 delete-utils:暫時性錯誤(429/5xx、 網路例外)重試、永久性錯誤(401/403/404)即止、單筆失敗只記錄不中斷並彙報失敗數, 並以有上限併發加速。解決 AI review 的重試/錯誤分流/併發/部分失敗續行系列建議。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/releases.js | 35 +++++++++++++++++++++-------------- app/tags.js | 27 ++++++++++++++++++--------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/app/releases.js b/app/releases.js index b338a17..c4ec8ba 100644 --- a/app/releases.js +++ b/app/releases.js @@ -3,6 +3,7 @@ import { section, info, success, fail, warn } from './logger.js' import { isEmptyOrNull } from './validate.js' +import { deleteWithRetry, runWithConcurrency } from './delete-utils.js' /** * 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。 @@ -34,8 +35,8 @@ export function selectReleasesToDelete(releases, keepCount) { * 讀取全部成品清單,刪除超出保留數量的舊版本成品。 * * 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→ - * 否則以 [[selectReleasesToDelete]] 取出待刪除清單,逐筆刪除(略過沒有 `id` 的項目), - * 依回應狀態碼 204 判定成功與否並輸出結果。 + * 否則以 [[selectReleasesToDelete]] 取出待刪除清單,過濾結構異常/無效 id 的項目後, + * 以有上限併發、暫時性錯誤重試的方式逐筆刪除(best-effort:單筆失敗會記錄並繼續),最後彙報失敗筆數。 * * @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端 * @param {ReturnType} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount` @@ -55,32 +56,38 @@ export async function cleanupReleases(client, config) { } section('刪除舊版本成品') - const toDelete = selectReleasesToDelete(releases, config.keepCount) - for (const release of toDelete) { - // 防禦非預期結構(null/非物件),避免解構時拋出未捕捉例外。 + // 先過濾出可安全刪除的項目:略過 null/非物件結構,以及非正整數 id(Gitea release id 本即正整數)。 + const targets = selectReleasesToDelete(releases, config.keepCount).filter((release) => { if (release === null || typeof release !== 'object') { warn('略過格式異常的成品項目') - continue + return false } - const { id, tag_name: tag, name } = release - - // 要求 id 為正整數(Gitea release id 本即正整數);非整數一律略過,不僅依賴 URL 編碼防護。 if (isEmptyOrNull(id) || !Number.isInteger(Number(id)) || Number(id) <= 0) { warn(`略過沒有有效 id 的成品: ${tag} (${name})`) - continue + return false } + return true + }) - // id 已驗證為正整數;仍對其編碼作為縱深防禦。 + let failures = 0 + // 有上限併發 + 暫時性錯誤重試;單筆刪除失敗(含網路例外)只記錄不中斷,確保其餘成品仍被嘗試清理。 + await runWithConcurrency(targets, async (release) => { + const { id, tag_name: tag, name } = release const url = `${config.releaseApiUrl}/${encodeURIComponent(id)}` info(`DELETE ${tag} (${name})`) - const code = await client.deleteResource(url) - if (code === 204) { + const { status, error } = await deleteWithRetry(client, url) + if (status === 204) { success(`成功刪除: ${tag} (${name})`) } else { - fail(`刪除失敗: ${tag} (${name}), HTTP ${code}`) + failures += 1 + fail(`刪除失敗: ${tag} (${name}), ${error ? error.message : `HTTP ${status}`}`) } + }) + + if (failures > 0) { + warn(`有 ${failures} 個成品刪除失敗(已記錄;清理為冪等,將於下次執行重試)`) } } diff --git a/app/tags.js b/app/tags.js index 4668611..8c38a61 100644 --- a/app/tags.js +++ b/app/tags.js @@ -3,6 +3,7 @@ import { section, info, success, fail, warn } from './logger.js' import { isEmptyOrNull } from './validate.js' +import { deleteWithRetry, runWithConcurrency } from './delete-utils.js' /** * 將每個 tag 分類為保留、刪除或略過三類,判斷優先序為:無名稱 → skip、仍被指定 → keep、其餘 → delete。 @@ -55,26 +56,34 @@ export async function cleanupOrphanTags(client, config) { const tags = await client.fetchAllPages(config.tagApiUrl) info(`TAG_COUNT=${tags.length}`) - for (const { tag, action } of categorizeTags(tags, releaseTagNames)) { + const categorized = categorizeTags(tags, releaseTagNames) + for (const { tag, action } of categorized) { if (action === 'skip') { warn('略過沒有名稱的 tag') - continue - } - - if (action === 'keep') { + } else if (action === 'keep') { info(`保留指定 release 的 tag: ${tag.name}`) - continue } + } + // 取出孤立 tag,以有上限併發 + 暫時性錯誤重試刪除;單筆失敗只記錄不中斷,確保其餘 tag 仍被嘗試清理。 + const orphans = categorized.filter(({ action }) => action === 'delete').map(({ tag }) => tag) + + let failures = 0 + await runWithConcurrency(orphans, async (tag) => { // 對 tag 名稱做編碼,避免名稱中的特殊字元被拼接進 URL(防路徑穿越);一般 tag 名編碼後不變。 const url = `${config.tagApiUrl}/${encodeURIComponent(tag.name)}` info(`DELETE tag ${tag.name}`) - const code = await client.deleteResource(url) - if (code === 204) { + const { status, error } = await deleteWithRetry(client, url) + if (status === 204) { success(`成功刪除未指定 release 的 tag: ${tag.name}`) } else { - fail(`刪除 tag 失敗: ${tag.name}, HTTP ${code}`) + failures += 1 + fail(`刪除 tag 失敗: ${tag.name}, ${error ? error.message : `HTTP ${status}`}`) } + }) + + if (failures > 0) { + warn(`有 ${failures} 個 tag 刪除失敗(已記錄;清理為冪等,將於下次執行重試)`) } }