refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #5

Closed
jiantw83 wants to merge 43 commits from refactor/nodejs-rewrite-20260626-103443 into develop
4 changed files with 59 additions and 26 deletions
Showing only changes of commit 7e6f9ef3f6 - Show all commits
+11 -3
View File
@@ -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` 為選填,
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。
建議:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。 **建議**:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。
* 為空時改以匿名方式呼叫 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('參數檢查')
6
+17 -6
View File
@@ -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>` 標頭,
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:目前 MAX_PAGES 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。
建議:建議將 MAX_PAGES 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:目前 `MAX_PAGES` 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。 **建議**:建議將 `MAX_PAGES` 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。
* 無 token(或為 `null`)時以匿名方式呼叫。
* @param {{ token?: string | null }} [options] 認證設定
*/
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。
建議:請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。 **建議**:請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。
constructor({ token = null } = {}) {
this.headers = {}
@@ -13,9 +19,14 @@ export class GiteaClient {
}
/**
* 逐頁讀取分頁式清單 API,直到回傳空陣列為止,合併成單一陣列。
* @param {string} baseUrl 不含 query string 的 API 位址
* @returns {Promise<any[]>} 所有頁面合併後的項目
* 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列(或非陣列)為止,
* 將所有頁面項目合併成單一陣列回傳。
*
* 注意:終止條件依賴「空陣列代表最後一頁」的假設;若 API 不以空陣列結尾則迴圈不會自然停止。
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:在 GiteaClient 建構子中,this.headers 初始化時僅簡單檢查 token 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。
建議:考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 _getHeaders()),即便現在很簡單,也能增加未來的彈性。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:在 `GiteaClient` 建構子中,`this.headers` 初始化時僅簡單檢查 `token` 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。 **建議**:考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 `_getHeaders()`),即便現在很簡單,也能增加未來的彈性。
*
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
* @throws {Error} 任一頁回應 HTTP 非 2xx(`!res.ok`)時拋出,訊息含 URL 與狀態碼
*/
async fetchAllPages(baseUrl) {
const all = []
4
@@ -42,9 +53,9 @@ export class GiteaClient {
}
/**
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。
建議:將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。 **建議**:將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。
* 對指定資源發出 DELETE 請求。
* 對指定資源發出 DELETE 請求,僅回傳 HTTP 狀態碼供呼叫端判斷成敗(不檢查 ok、不解析回應內容)
* @param {string} url 目標資源位址
* @returns {Promise<number>} HTTP 狀態碼
* @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204)
*/
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Bard
問題:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
建議:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。

**嚴重等級**:🔴 嚴重 **審查員**:Bard **問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。 **建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
Review

嚴重等級🔴 嚴重
審查員:Bard
問題:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
建議:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。

**嚴重等級**:🔴 嚴重 **審查員**:Bard **問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。 **建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
async deleteResource(url) {
const res = await fetch(url, { method: 'DELETE', headers: this.headers })
2
+14 -8
View File
@@ -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) {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:使用 new Date(release.created_at) 進行排序時,若 created_at 格式非預期或為空,會導致 NaN 並造成排序異常,可能無法正確刪除舊版本。
建議:建議在排序邏輯中增加對 created_at 的有效性檢查,若無效則給予預設值(例如:new Date(0)),確保排序結果可預期。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:使用 `new Date(release.created_at)` 進行排序時,若 `created_at` 格式非預期或為空,會導致 `NaN` 並造成排序異常,可能無法正確刪除舊版本。 **建議**:建議在排序邏輯中增加對 `created_at` 的有效性檢查,若無效則給予預設值(例如:`new Date(0)`),確保排序結果可預期。
const sorted = [...releases].sort(
1
@@ -19,9 +19,15 @@ export function selectReleasesToDelete(releases, keepCount) {
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:在 selectReleasesToDelete 函式中,對於 Date.parse 無法解析的日期直接視為 0,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。
建議:應明確記錄無效日期的處理方式(例如記錄 warning),或在 Date.parse 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:在 `selectReleasesToDelete` 函式中,對於 `Date.parse` 無法解析的日期直接視為 `0`,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。 **建議**:應明確記錄無效日期的處理方式(例如記錄 warning),或在 `Date.parse` 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。
/**
* 讀取成品清單,刪除超出保留數量的舊版本成品。
* @param {import('./gitea-client.js').GiteaClient} client
* @param {ReturnType<import('./config.js').loadConfig>} config
* 讀取全部成品清單,刪除超出保留數量的舊版本成品。
*
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Rogue
問題:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。
建議:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。

**嚴重等級**:🔵 建議 **審查員**:Rogue **問題**:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。 **建議**:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 selectReleasesToDelete 的排序邏輯中,若多個成品具有完全相同的 created_at 時間戳,目前的排序行為依賴於 JavaScript 引擎對 sort() 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。
建議:建議在排序邏輯中加入次要的排序鍵值(如 idtag_name)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `selectReleasesToDelete` 的排序邏輯中,若多個成品具有完全相同的 `created_at` 時間戳,目前的排序行為依賴於 JavaScript 引擎對 `sort()` 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。 **建議**:建議在排序邏輯中加入次要的排序鍵值(如 `id` 或 `tag_name`)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。
* 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→
* 否則以 [[selectReleasesToDelete]] 取出待刪除清單,逐筆刪除(略過沒有 `id` 的項目),
* 依回應狀態碼 204 判定成功與否並輸出結果。
*
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:在 cleanupReleases 函式中,參數 configReturnType<import('./config.js').loadConfig>,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。
建議:建議在 app/config.js 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:在 `cleanupReleases` 函式中,參數 `config` 是 `ReturnType<import('./config.js').loadConfig>`,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。 **建議**:建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。
Review

嚴重等級🔵 建議
審查員:Bard
問題:config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。
建議:建議在 app/config.js 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。 **建議**:建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount`
* @returns {Promise<void>}
*/
export async function cleanupReleases(client, config) {
section('取得成品資訊')
18
+17 -9
View File
@@ -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<string>} 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<string>} releaseTagNames 仍被 release 指定的 tag 名稱集合
* @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} 與輸入等長且保持原順序的分類結果
*/
export function categorizeTags(tags, releaseTagNames) {
const keep = new Set(releaseTagNames)
2
@@ -27,9 +29,15 @@ export function categorizeTags(tags, releaseTagNames) {
}
/**
* 重新讀取成品清單以取得仍被指定的 tag,再刪除未指定 release 的孤立 tag。
* @param {import('./gitea-client.js').GiteaClient} client
* @param {ReturnType<import('./config.js').loadConfig>} config
* 重新讀取成品清單以取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。
*
* 流程:重新抓取全部 release(反映刪除舊版本後的最新狀態)並取出其 `tag_name` 集合 →
* 抓取全部 tag → 以 [[categorizeTags]] 分類,對 `skip` 警告略過、`keep` 保留,
* 對 `delete` 發出刪除並依狀態碼 204 判定成功與否。
*
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `tagApiUrl`
* @returns {Promise<void>}
*/
export async function cleanupOrphanTags(client, config) {
section('刪除未指定 release 的 tag')
12