// 清理舊版本成品的功能模組。 // 對應原本 entrypoint.sh 的「取得成品資訊」與「刪除舊版本成品」區段。 import { section, info, success, fail, warn } from './logger.js' import { isEmptyOrNull } from './validate.js' import { deleteWithRetry, runWithConcurrency } from './delete-utils.js' /** * 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。 * 以展開運算子複製陣列後再排序,不會改動傳入的原陣列。為純函式,方便單元測試。 * @param {any[]} releases 成品清單,每個元素預期含有 `created_at` 欄位 * @param {number} keepCount 要保留的最新筆數;為 0 時代表全部刪除 * @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序 */ export function selectReleasesToDelete(releases, keepCount) { // 解析 created_at;格式無效或缺漏時視為最舊(0),避免 NaN 造成排序結果不可預期。 const createdTime = (release) => { const time = Date.parse(release?.created_at) return Number.isNaN(time) ? 0 : time } // 先一次性計算每筆的時間戳,避免在排序比較中重複呼叫 Date.parse。 // 時間相同時以 id(數值,大者為新)作為次要鍵,確保排序結果具決定性。 return releases .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) } /** * 讀取全部成品清單,刪除超出保留數量的舊版本成品。 * * 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→ * 否則以 [[selectReleasesToDelete]] 取出待刪除清單,過濾結構異常/無效 id 的項目後, * 以有上限併發、暫時性錯誤重試的方式逐筆刪除(best-effort:單筆失敗會記錄並繼續),最後彙報失敗筆數。 * * @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端 * @param {ReturnType} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount` * @returns {Promise} */ 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('刪除舊版本成品') // 先過濾出可安全刪除的項目:略過 null/非物件結構,以及非正整數 id(Gitea release id 本即正整數)。 const targets = selectReleasesToDelete(releases, config.keepCount).filter((release) => { if (release === null || typeof release !== 'object') { warn('略過格式異常的成品項目') return false } const { id, tag_name: tag, name } = release if (isEmptyOrNull(id) || !Number.isInteger(Number(id)) || Number(id) <= 0) { warn(`略過沒有有效 id 的成品: ${tag} (${name})`) return false } return true }) 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 { status, error } = await deleteWithRetry(client, url) if (status === 204) { success(`成功刪除: ${tag} (${name})`) } else { failures += 1 fail(`刪除失敗: ${tag} (${name}), ${error ? error.message : `HTTP ${status}`}`) } }) if (failures > 0) { warn(`有 ${failures} 個成品刪除失敗(已記錄;清理為冪等,將於下次執行重試)`) } }