// 與 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 操作。 */ export class GiteaClient { /** * 建立客戶端;有 token 時於後續所有請求帶上 `Authorization: token ` 標頭, * 無 token(或為 `null`)時以匿名方式呼叫。 * @param {{ token?: string | null }} [options] 認證設定 */ constructor({ token = null } = {}) { this.headers = {} if (token) { this.headers.Authorization = `token ${token}` } } /** * 逐頁讀取分頁式清單 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 = [] let page = 1 while (true) { const url = `${baseUrl}?page=${page}` const res = await fetch(url, { headers: this.headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) if (!res.ok) { 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() if (!Array.isArray(items) || items.length === 0) { break } all.push(...items) page += 1 } return all } /** * 對指定資源發出 DELETE 請求,僅回傳 HTTP 狀態碼供呼叫端判斷成敗(不檢查 ok、不解析回應內容)。 * @param {string} url 目標資源位址 * @returns {Promise} 回應的 HTTP 狀態碼(成功刪除通常為 204) */ async deleteResource(url) { const res = await fetch(url, { method: 'DELETE', headers: this.headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) return res.status } }