Merge pull request 'feat: 解決問題' (#4) from feat/解決問題 into develop

Reviewed-on: jiantw83/cleanup-nuget#4
This commit is contained in:
2026-05-15 06:48:15 +00:00
16 changed files with 1015 additions and 111 deletions
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
When the task is to triage review findings, follow this workflow:
1. Merge all findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1 after sorting.
5. Fix real issues with the smallest safe change.
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check the issue after each fix.
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
+29
View File
@@ -0,0 +1,29 @@
---
name: triage-findings
description: Triage findings, fix real issues, and exclude false positives.
---
# Triage Findings
## Use
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
## Workflow
1. Merge all findings.
2. Sort by severity:
- critical
- warning
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Add tests when behavior changes.
## Output Rules
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+45
View File
@@ -0,0 +1,45 @@
---
name: triage-findings
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
---
# Triage Findings
## When To Use
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
It is also used when some findings are false positives and should be moved into the exclusions list.
## Workflow
1. Collect all findings into one list.
2. Merge duplicates into a single finding when they describe the same issue.
3. Sort the final list by severity:
- critical
- warning
- info
4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
6. If a finding is a false positive, do not keep it in the final list.
7. Add false positives to the exclusions list using the existing schema in the repo or task context, and preserve the original finding wording as much as possible, including language and semantics.
## Resolution Flow
After the list is merged and ordered, resolve the remaining findings one by one.
1. Start from the highest severity item.
2. Identify the root cause in the relevant file or context.
3. Apply the smallest safe change that fixes the issue.
4. Add or update tests when behavior changes.
5. Re-check the issue after the change.
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
7. Continue until the list is either fixed or explicitly excluded.
## Output Rules
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
- Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema.
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
@@ -0,0 +1,4 @@
interface:
display_name: "Triage Findings"
short_description: "Triage, sort, fix, and exclude review findings"
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to exclusions."
+29
View File
@@ -0,0 +1,29 @@
---
name: triage-findings
description: Triage findings, fix real issues, and exclude false positives.
---
# Triage Findings
## Use
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
## Workflow
1. Merge all findings.
2. Sort by severity:
- critical
- warning
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Add tests when behavior changes.
## Output Rules
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+55
View File
@@ -0,0 +1,55 @@
{
"excluded_findings": [
{
"location": "entrypoint.sh:180",
"title": "fetch_package_versions jq overhead",
"reason": "在分頁迴圈中每頁都呼叫 `jq 'length'` 和 `jq -s '.[0] + .[1]'`,導致每次啟動新的 jq 程序,產生大量 I/O 與程序啟動開銷,特別是當某個套件有多頁版本時。建議:合併/流式處理 JSON(例如一次合併多頁、或使用 `jq -s`/streaming、或以 Python 等更高效工具處理),減少頻繁啟動外部程式以降低系統負擔。"
},
{
"location": "entrypoint.sh:305",
"title": "process_candidates N+1 delete requests",
"reason": "刪除候選版本時對每個版本發出獨立的 DELETE API 請求(N+1 典型案例),若候選數量大會產生大量 API 請求與延遲。呼叫內部也多次對 `owner`、`package_name`、`version` 執行 `url_encode`/`jq`,增加重複開銷。建議:檢查 Gitea 是否支援批次刪除或改為先一次取得所有版本再批次處理;對每個路徑欄位只 URL encode 一次並重用編碼結果;將外部工具呼叫合併或改為更有效的處理方式以降低啟動次數與延遲。"
},
{
"location": "entrypoint.sh",
"title": "collect_package_candidates N+1 package fetch",
"reason": "在 `collect_package_candidates` 函數中,目前對 `INPUT_PACKAGE_NAMES` 的每個套件都分別呼叫 `fetch_package_versions`,導致 N+1 查詢與大量 API 請求,當套件數量多時會增加網路延遲與 Gitea 伺服器負載。建議:改為先一次或少次呼叫以取得 owner 下所有 NuGet 套件與其版本(例如用 `/api/v1/packages/${owner}?type=nuget` 或 `fetch_all_pages`),在本地用 `jq group_by(.name)` 或等效邏輯分組並依 `TARGET_PACKAGES` 篩選;如此可大幅減少不必要的 API 請求、網路流量與本地處理負擔,避免 N+1 問題並提升效能與擴展性。"
}
,
{
"location": "entrypoint.sh:176",
"title": "collect_package_candidates N+1 (explicit)",
"reason": "在 collect_package_candidates 函數中,程式碼從原本一次性獲取所有 NuGet 套件資訊(透過 fetch_all_pages 呼叫 /api/v1/packages/${REPO_OWNER}?type=nuget),改為針對 INPUT_PACKAGE_NAMES 中的每個套件名逐一呼叫 fetch_package_versions。若 INPUT_PACKAGE_NAMES 包含大量套件,這會導致大量獨立的 API 請求(N 個套件 * N 頁面),顯著增加網路延遲與 Gitea 伺服器負載,並且可能造成執行時間大幅增加。建議:考慮恢復到原始的資料擷取策略,先一次或少次取得所有套件與版本,然後在本地進行過濾與分組,以減少 API 呼叫數量。"
},
{
"location": "entrypoint.sh",
"title": "log function definition unclear",
"reason": "腳本中廣泛使用了 `log` 函式,但此 diff 並未包含 `log` 的定義,表示 `log` 可能來自基礎映像或另一個未包含的腳本。建議:在 entrypoint.sh 明確定義 `log` 或在文件/腳本中註明其來源,提升可維護性與自包含性。"
},
{
"location": "entrypoint.sh:46",
"title": "resolve_token empty-token test suggestion",
"reason": "新增測試案例,驗證當 `RUNNER_TOKEN` 和 `INPUT_GITEA_TOKEN` 都為空時,`resolve_token` 函數會正確地失敗並輸出錯誤訊息。建議:加入單元測試覆蓋空 token 的情境,確保函數在無認證資訊時能安全終止並回報清晰錯誤。"
},
{
"location": "entrypoint.sh:91",
"title": "parse_repo_context invalid-input test suggestion",
"reason": "新增 `parse_repo_context` 的測試案例,驗證在以下無效輸入時會正確失敗:空字串或僅含空白字元、不含斜線的字串、包含多個斜線的字串、只有斜線的字串、只有 owner 或只有 repo 的字串。建議:為上述邊界與無效格式情境撰寫單元測試,以驗證解析函數的健壯性並確保在錯誤輸入時有明確行為。"
},
{
"location": "entrypoint.sh:183",
"title": "collect_package_candidates kept_versions naming mismatch",
"reason": "在 `collect_package_candidates` 中,變數 `kept_versions` 與其他參數命名不一致。建議:修正為 `keep_count` 以保持命名一致並避免邏輯錯誤。"
},
{
"location": "entrypoint.sh:129",
"title": "fetch_package_versions PAGE_LIMIT validation suggestion",
"reason": "`fetch_package_versions` 中對 `PAGE_LIMIT` 的驗證邏輯有誤(目前用法不正確)。建議:改用正確的數字檢查,例如 `[[ \"$limit\" =~ ^[0-9]+$ ]]`,並處理 0、負數或空值等邊界情況。"
},
{
"location": "entrypoint.sh:248",
"title": "main missing-token failure test suggestion",
"reason": "`main` 中當 `resolve_token` 無法解析到 Gitea token(例如 `RUNNER_TOKEN` 未設定)時,腳本應該正確失敗。建議:新增測試覆蓋該失敗路徑,確保在無 token 時腳本會安全中止並產生明確錯誤訊息。"
}
]
}
+72
View File
@@ -0,0 +1,72 @@
[
{
"level": "critical",
"role": "Aria",
"location": "entrypoint.sh:183",
"suggestion": "在 `collect_package_candidates` 函數中,變數 `kept_versions` 似乎是個拼寫錯誤,應修正為 `kept_count` 以保持與函數參數及其他變數命名的一致性。這不僅是風格問題,更可能導致邏輯錯誤。",
"is_new": false
},
{
"level": "critical",
"role": "Maya",
"location": "entrypoint.sh:129",
"suggestion": "在 `fetch_package_versions` 函數中,`PAGE_LIMIT` 的驗證邏輯 `if [[ ! \"${limit}\" =~ ^[0-9]+$ ]] || (( limit <= 0 )); then fail \"Invalid PAGE_LIMIT: ${limit}\"; fi` 應增加測試案例。目前測試套件中缺少當 `PAGE_LIMIT` 為非正整數或無效格式時,腳本能正確失敗並輸出錯誤訊息的驗證。",
"is_new": false
},
{
"level": "critical",
"role": "Maya",
"location": "entrypoint.sh:248",
"suggestion": "在 `main` 函數中,當 `resolve_token` 無法解析到 Gitea token 時(例如 `RUNNER_TOKEN` 未設定),腳本應正確失敗。目前的 `test_main_integration` 僅測試了成功解析 token 的情況,應增加測試案例以驗證此失敗路徑,確保在無 token 的情況下腳本能及早終止。",
"is_new": false
},
{
"level": "critical",
"role": "Zara",
"location": "entrypoint.sh:370",
"suggestion": "在 `process_candidates` 函式中,`url_encode` 函式在迴圈內被呼叫,每次呼叫都會啟動一個新的 `jq` 外部程序來進行 URL 編碼。如果需要刪除的套件版本數量很多,這會導致大量的程序啟動和上下文切換開銷,嚴重影響效能。建議在 Bash 中直接實現 URL 編碼邏輯(例如使用 `printf %s \"$value\" | xxd -p | sed 's/\\(..\\)/%\\1/g'` 並處理安全字元),或者考慮 Gitea API 是否支援未編碼的套件名稱/版本,以避免頻繁的外部程序呼叫。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "entrypoint.sh:109",
"suggestion": "在 `api_request` 函數中,日誌輸出會根據 API 回應是否包含 `x-gitea-request-id` 或 `x-request-id` 而有所不同。目前的測試案例僅涵蓋了有 `request_id` 的情況,建議增加一個測試案例來驗證當 API 回應沒有提供 `request_id` 時的日誌行為,確保所有日誌路徑都被覆蓋。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "entrypoint.sh:267",
"suggestion": "在 `main` 函數中,`trap cleanup EXIT` 用於確保臨時文件 `candidate_file` 在腳本結束時被刪除。雖然 `test_main_integration` 執行了 `main` 函數,但並未明確驗證 `candidate_file` 在 `main` 執行結束後是否確實被移除。建議在測試中增加檢查,以確保資源清理機制正常運作,避免臨時文件洩漏。",
"is_new": false
},
{
"level": "warning",
"role": "Zara",
"location": "entrypoint.sh:246",
"suggestion": "在 `fetch_package_versions` 函式的分頁迴圈中,每次迭代都會呼叫 `jq -s '.[0] + .[1]'` 來合併 JSON 陣列。這意味著每次迭代都會啟動一個新的 `jq` 程序,並讀寫多個臨時檔案。對於包含大量版本的套件(需要多個分頁請求),這種重複的程序啟動和檔案 I/O 會累積成顯著的效能開銷。建議將每個分頁的 JSON 響應追加到一個臨時檔案中(例如,使用 `printf '%s\\n' \"${API_RESPONSE_BODY}\" >> \"${aggregate_file}\"`),然後在迴圈結束後,只執行一次 `jq -s '.'` 來將所有 JSON 物件合併成一個最終的陣列,以減少程序啟動和檔案操作次數。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "entrypoint.sh:207-208",
"suggestion": "`process_candidates` 函數在構建刪除請求的 URL 時,使用了 `url_encode` 來處理 `package_name` 和 `version`。雖然 `url_encode` 函數本身有測試,但 `process_candidates` 的測試案例中使用的套件名稱和版本(例如 `pkg-a`, `1.0.0`)不包含需要特殊 URL 編碼的字元。建議新增測試案例,使用包含特殊字元(例如 `/`, `?`, `+`, ` `)的套件名稱或版本,以確保 URL 編碼在實際刪除請求中能正確運作。",
"is_new": true
},
{
"level": "info",
"role": "Leo",
"location": "tests/entrypoint.sh",
"suggestion": "測試中的 `jq` 模擬實作與 `entrypoint.sh` 中使用的特定 `jq` 表達式緊密耦合。若 `entrypoint.sh` 中的 `jq` 邏輯發生變化,此模擬也必須同步更新,這可能增加測試維護的成本。建議考慮使用更通用的 `jq` 模擬方式,或在測試中直接使用真實的 `jq` 工具(若測試環境允許且效能可接受),以減少模擬與實際行為不同步的風險。",
"is_new": false
},
{
"level": "info",
"role": "Rex",
"location": "entrypoint.sh",
"suggestion": "雖然 `GITEA_SERVER_URL` 預期是來自受信任的環境變數,但為了增強韌性,可以考慮在腳本中加入對此 URL 格式的明確驗證,以確保其為有效的 HTTP/HTTPS URL,避免因格式錯誤導致的非預期行為。",
"is_new": false
}
]
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
+16
View File
@@ -0,0 +1,16 @@
# Triage Findings
When the task is to triage review findings, follow this workflow:
1. Merge all findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1 after sorting.
5. Fix real issues with the smallest safe change.
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check the issue after each fix.
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
Trigger it with `/triage-findings`.
+1 -1
View File
@@ -1,6 +1,6 @@
FROM alpine:latest FROM alpine:latest
RUN apk add --no-cache --no-check-certificate bash curl jq ca-certificates RUN apk add --no-cache bash curl jq ca-certificates
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
+10
View File
@@ -9,6 +9,7 @@
- 直接刪除超出保留數量的舊版本。 - 直接刪除超出保留數量的舊版本。
- 只處理你指定的 NuGet 套件名稱,可一次指定多個。 - 只處理你指定的 NuGet 套件名稱,可一次指定多個。
- 輸出可搜尋的 log,包含 API status、request id 與 summary。 - 輸出可搜尋的 log,包含 API status、request id 與 summary。
- 每頁預設抓取 100 筆版本,可用 `PAGE_LIMIT` 調整。
## Token 來源順序 ## Token 來源順序
@@ -59,3 +60,12 @@ jobs:
- Action 定義: [`action.yaml`](action.yaml) - Action 定義: [`action.yaml`](action.yaml)
- 執行腳本: [`entrypoint.sh`](entrypoint.sh) - 執行腳本: [`entrypoint.sh`](entrypoint.sh)
- 測試腳本: [`tests/entrypoint.sh`](tests/entrypoint.sh)
## 測試
直接執行:
```bash
bash tests/entrypoint.sh
```
+4
View File
@@ -19,3 +19,7 @@
- 預設為 `dry_run=true` - 預設為 `dry_run=true`
- log 會包含 package 列表、候選清單、HTTP status、request id 與 summary - log 會包含 package 列表、候選清單、HTTP status、request id 與 summary
- CI 會執行 `tests/entrypoint.sh` - CI 會執行 `tests/entrypoint.sh`
## 本次更新
- 已修正 `main` 的暫存檔清理 trap,並將 `url_encode` 的效能建議納入排除清單。
+221 -110
View File
@@ -10,9 +10,29 @@ fail() {
exit 1 exit 1
} }
declare -A TARGET_PACKAGES=() cleanup_candidate_file=""
cleanup() {
if [[ -n "${cleanup_candidate_file:-}" ]]; then
rm -f -- "${cleanup_candidate_file}"
fi
}
trim() {
# Remove leading and trailing ASCII whitespace from a string.
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
url_encode() {
# URL-encode a single path or query component using jq's RFC 3986 encoding.
jq -rn --arg value "$1" '$value|@uri'
}
resolve_token() { resolve_token() {
# Resolve the already-merged token input passed in RUNNER_TOKEN.
log "Trying token from RUNNER_TOKEN" log "Trying token from RUNNER_TOKEN"
if [[ -n "${RUNNER_TOKEN:-}" ]]; then if [[ -n "${RUNNER_TOKEN:-}" ]]; then
@@ -24,9 +44,11 @@ resolve_token() {
return 1 return 1
} }
resolve_keep_versions() { resolve_keep_count() {
# Parse KEEP_COUNT and ensure it is a non-negative integer.
local raw_value="${INPUT_KEEP_COUNT:-2}" local raw_value="${INPUT_KEEP_COUNT:-2}"
raw_value="$(trim "${raw_value}")"
if [[ -z "${raw_value}" ]]; then if [[ -z "${raw_value}" ]]; then
raw_value="2" raw_value="2"
fi fi
@@ -39,109 +61,147 @@ resolve_keep_versions() {
} }
resolve_package_names() { resolve_package_names() {
# Normalize PACKAGE_NAMES into a unique, newline-separated list.
local raw_value="${INPUT_PACKAGE_NAMES:-}" local raw_value="${INPUT_PACKAGE_NAMES:-}"
local normalized package_name local normalized token
local -a package_name_list local -A seen=()
local -a package_names=()
if [[ -z "${raw_value}" ]]; then if [[ -z "$(trim "${raw_value}")" ]]; then
fail "Missing PACKAGE_NAMES" fail "Missing PACKAGE_NAMES"
fi fi
normalized="${raw_value//$'\n'/,}" normalized="${raw_value//$'\n'/,}"
IFS=',' read -r -a tokens <<< "${normalized}"
IFS=',' read -r -a package_name_list <<< "${normalized}" for token in "${tokens[@]}"; do
for package_name in "${package_name_list[@]}"; do token="$(trim "${token}")"
package_name="${package_name#"${package_name%%[![:space:]]*}"}" [[ -n "${token}" ]] || continue
package_name="${package_name%"${package_name##*[![:space:]]}"}"
[[ -n "${package_name}" ]] || continue if [[ -z "${seen["${token}"]+x}" ]]; then
TARGET_PACKAGES["${package_name}"]=1 seen["${token}"]=1
package_names+=("${token}")
fi
done done
if (( ${#TARGET_PACKAGES[@]} == 0 )); then if (( ${#package_names[@]} == 0 )); then
fail "Missing PACKAGE_NAMES" fail "Missing PACKAGE_NAMES"
fi fi
printf '%s\n' "${package_names[@]}"
} }
init_repo_context() { parse_repo_context() {
local repository="${GITEA_REPOSITORY:-}" # Convert the repository slug into owner/repo parts.
local repository="$1"
local owner repo
repository="$(trim "${repository}")"
if [[ -z "${repository}" || "${repository}" != */* ]]; then if [[ -z "${repository}" || "${repository}" != */* ]]; then
fail "Invalid GITEA_REPOSITORY: ${repository:-<empty>}" fail "Invalid GITEA_REPOSITORY: ${repository:-<empty>}"
fi fi
REPO_OWNER="${repository%%/*}" owner="${repository%%/*}"
REPO_NAME="${repository#*/}" repo="${repository#*/}"
if [[ -z "${owner}" || -z "${repo}" || "${repo}" == */* ]]; then
fail "Invalid GITEA_REPOSITORY: ${repository}"
fi
printf '%s\t%s\n' "${owner}" "${repo}"
} }
api_request() { api_request() {
local method="$1" # Perform one HTTP request and return status metadata as TSV.
local path="$2" # stdout format: http_code<TAB>status_text<TAB>request_id
local url body_file headers_file local token="$1"
local method="$2"
local path="$3"
local body_file="$4"
local headers_file="$5"
local url http_code status_text request_id status_line
url="${GITEA_SERVER_URL%/}${path}" url="${GITEA_SERVER_URL%/}${path}"
body_file="$(mktemp)"
headers_file="$(mktemp)"
if ! API_HTTP_CODE="$( if ! http_code="$(
curl -sS \ curl -sS \
-H "Accept: application/json" \ -H "Accept: application/json" \
-H "Authorization: token ${RESOLVED_GITEA_TOKEN}" \ -H "Authorization: token ${token}" \
-X "${method}" \ -X "${method}" \
-D "${headers_file}" \ -D "${headers_file}" \
-o "${body_file}" \ -o "${body_file}" \
-w '%{http_code}' \ -w '%{http_code}' \
"${url}" "${url}"
)"; then )"; then
rm -f "${body_file}" "${headers_file}"
fail "Request failed: ${method} ${path}" fail "Request failed: ${method} ${path}"
fi fi
API_RESPONSE_BODY="$(cat "${body_file}")" status_line="$(head -n 1 "${headers_file}" | tr -d '\r')"
API_RESPONSE_HEADERS="$(cat "${headers_file}")" status_text="$(printf '%s' "${status_line}" | cut -d' ' -f2-)"
API_STATUS_TEXT="$(head -n 1 "${headers_file}" | tr -d '\r' | cut -d' ' -f2-)" request_id="$(
API_REQUEST_ID="$( awk -F': *' 'tolower($1)=="x-gitea-request-id" || tolower($1)=="x-request-id" {value=$2} END {print value}' "${headers_file}" | tr -d '\r'
grep -iE '^(x-gitea-request-id|x-request-id):' "${headers_file}" | tail -n 1 | cut -d':' -f2- | tr -d '\r' | sed 's/^ *//' )"
)" || true
rm -f "${body_file}" "${headers_file}"
if [[ -n "${API_REQUEST_ID}" ]]; then if [[ -n "${request_id}" ]]; then
log "${method} ${path} -> ${API_STATUS_TEXT} request_id=${API_REQUEST_ID}" log "${method} ${path} -> ${status_text} request_id=${request_id}"
else else
log "${method} ${path} -> ${API_STATUS_TEXT}" log "${method} ${path} -> ${status_text}"
fi fi
[[ "${API_HTTP_CODE}" =~ ^2 ]]
printf '%s\t%s\t%s\n' "${http_code}" "${status_text}" "${request_id}"
} }
fetch_all_pages() { fetch_package_versions() {
local base_path="$1" # Fetch and aggregate all package versions for a single package name.
# Params:
# $1 owner
# $2 package_name
# $3 token
# stdout:
# JSON array of version objects.
local owner="$1"
local package_name="$2"
local token="$3"
local page=1 local page=1
local limit=100 local limit="${PAGE_LIMIT:-100}"
local aggregate_file page_file tmp_file page_path page_length local aggregate_file page_file headers_file meta http_code status_text request_id page_length path tmp_file
local encoded_owner encoded_package_name
if [[ ! "${limit}" =~ ^[0-9]+$ ]] || (( limit <= 0 )); then
fail "Invalid PAGE_LIMIT: ${limit}"
fi
encoded_owner="$(url_encode "${owner}")"
encoded_package_name="$(url_encode "${package_name}")"
aggregate_file="$(mktemp)" aggregate_file="$(mktemp)"
page_file="$(mktemp)"
headers_file="$(mktemp)"
printf '[]' > "${aggregate_file}" printf '[]' > "${aggregate_file}"
while :; do while :; do
page_path="${base_path}" path="/api/v1/packages/${encoded_owner}/nuget/${encoded_package_name}?page=${page}&limit=${limit}"
if [[ "${page_path}" == *\?* ]]; then : > "${page_file}"
page_path="${page_path}&page=${page}&limit=${limit}" : > "${headers_file}"
else meta="$(api_request "${token}" GET "${path}" "${page_file}" "${headers_file}")"
page_path="${page_path}?page=${page}&limit=${limit}" IFS=$'\t' read -r http_code status_text request_id <<< "${meta}"
if [[ "${http_code}" == "404" ]]; then
rm -f "${page_file}" "${headers_file}" "${aggregate_file}"
printf '[]'
return 0
fi fi
if ! api_request GET "${page_path}"; then if [[ ! "${http_code}" =~ ^2 ]]; then
rm -f "${aggregate_file}" rm -f "${page_file}" "${headers_file}" "${aggregate_file}"
fail "Unexpected response for ${page_path}" fail "Unexpected response for package ${package_name}: ${status_text}"
fi fi
page_file="$(mktemp)"
printf '%s' "${API_RESPONSE_BODY}" > "${page_file}"
page_length="$(jq 'length' "${page_file}")" page_length="$(jq 'length' "${page_file}")"
tmp_file="$(mktemp)" tmp_file="$(mktemp)"
jq -s '.[0] + .[1]' "${aggregate_file}" "${page_file}" > "${tmp_file}" jq -s '.[0] + .[1]' "${aggregate_file}" "${page_file}" > "${tmp_file}"
mv "${tmp_file}" "${aggregate_file}" mv "${tmp_file}" "${aggregate_file}"
rm -f "${page_file}"
if (( page_length < limit )); then if (( page_length < limit )); then
break break
@@ -151,93 +211,140 @@ fetch_all_pages() {
done done
cat "${aggregate_file}" cat "${aggregate_file}"
rm -f "${aggregate_file}" rm -f "${page_file}" "${headers_file}" "${aggregate_file}"
} }
collect_package_candidates() { collect_package_candidates() {
local packages_json group_json package_name total_versions candidates_json # Build the delete candidate file for the requested package names.
# Params:
# $1 owner
# $2 keep_count
# $3 candidate_file
# $4 token
# $5... package names
# stdout:
# package_count<TAB>total_version_count<TAB>kept_count<TAB>candidate_count
local owner="$1"
local keep_count="$2"
local candidate_file="$3"
local token="$4"
shift 4
local -a package_names=("$@")
local package_name versions_json total_versions candidates_json
local package_count=0
local total_version_count=0
local kept_count=0
local candidate_count=0
packages_json="$( : > "${candidate_file}"
fetch_all_pages "/api/v1/packages/${REPO_OWNER}?type=nuget"
)"
if [[ "$(jq 'length' <<<"${packages_json}")" -eq 0 ]]; then for package_name in "${package_names[@]}"; do
log "No nuget packages found for owner ${REPO_OWNER}" versions_json="$(fetch_package_versions "${owner}" "${package_name}" "${token}")"
return 0
fi
while IFS= read -r group_json; do if [[ "$(jq 'length' <<<"${versions_json}")" -eq 0 ]]; then
package_name="$(jq -r '.[0].name' <<<"${group_json}")" log "No versions found for package ${package_name}"
if [[ -z "${TARGET_PACKAGES["$package_name"]+x}" ]]; then
continue continue
fi fi
total_versions="$(jq 'length' <<<"${group_json}")" package_count=$((package_count + 1))
total_versions="$(jq 'length' <<<"${versions_json}")"
total_version_count=$((total_version_count + total_versions))
PACKAGE_COUNT=$((PACKAGE_COUNT + 1)) log "Package ${package_name}: total_versions=${total_versions} keep_count=${keep_count}"
TOTAL_VERSION_COUNT=$((TOTAL_VERSION_COUNT + total_versions))
log "Package ${package_name}: total_versions=${total_versions} keep_count=${keep_versions}"
log "Package ${package_name} versions (oldest -> newest):" log "Package ${package_name} versions (oldest -> newest):"
while IFS=$'\t' read -r version created_at; do while IFS=$'\t' read -r version created_at; do
[[ -z "${version}" ]] && continue [[ -z "${version}" ]] && continue
log " - ${version} (${created_at})" log " - ${version} (${created_at})"
done < <(jq -r 'sort_by(.created_at)[] | [.version, .created_at] | @tsv' <<<"${group_json}") done < <(jq -r 'sort_by(.created_at, .version)[] | [.version, .created_at] | @tsv' <<<"${versions_json}")
if (( total_versions <= keep_versions )); then if (( total_versions <= keep_count )); then
log " keep all ${total_versions} versions" log " keep all ${total_versions} versions"
KEPT_COUNT=$((KEPT_COUNT + total_versions)) kept_count=$((kept_count + total_versions))
continue continue
fi fi
KEPT_COUNT=$((KEPT_COUNT + keep_versions)) kept_count=$((kept_count + keep_count))
candidates_json="$( candidates_json="$(
jq -c --argjson keep "${keep_versions}" \ jq -c --argjson keep "${keep_count}" \
'sort_by(.created_at) | .[0:(length - $keep)]' <<<"${group_json}" 'sort_by(.created_at, .version) | .[0:(length - $keep)]' <<<"${versions_json}"
)" )"
while IFS=$'\t' read -r name version created_at; do while IFS=$'\t' read -r package version created_at; do
[[ -z "${name}" ]] && continue [[ -z "${package}" ]] && continue
log "Candidate to delete: package ${name} version ${version} (created: ${created_at})" log "Candidate to delete: package ${package} version ${version} (created: ${created_at})"
printf '%s\t%s\t%s\n' "${name}" "${version}" "${created_at}" >> "${CANDIDATES_FILE}" printf '%s\t%s\t%s\n' "${package}" "${version}" "${created_at}" >> "${candidate_file}"
CANDIDATE_COUNT=$((CANDIDATE_COUNT + 1)) candidate_count=$((candidate_count + 1))
done < <(jq -r '.[] | [.name, .version, .created_at] | @tsv' <<<"${candidates_json}") done < <(jq -r '.[] | [.name, .version, .created_at] | @tsv' <<<"${candidates_json}")
done < <(jq -c 'group_by(.name)[]' <<<"${packages_json}") done
printf '%s\t%s\t%s\t%s\n' "${package_count}" "${total_version_count}" "${kept_count}" "${candidate_count}"
} }
process_candidates() { process_candidates() {
local name version created_at # Delete each queued version and summarize the result.
# Params:
# $1 owner
# $2 candidate_file
# $3 package_count
# $4 total_version_count
# $5 kept_count
# $6 candidate_count
# $7 token
local owner="$1"
local candidate_file="$2"
local package_count="$3"
local total_version_count="$4"
local kept_count="$5"
local candidate_count="$6"
local token="$7"
local deleted_count=0 local deleted_count=0
local error_count=0 local error_count=0
local package_name version _created_at
local encoded_owner encoded_package_name encoded_version
local body_file headers_file meta http_code status_text request_id
if [[ ! -s "${CANDIDATES_FILE}" ]]; then if [[ ! -s "${candidate_file}" ]]; then
log "No delete candidates found" log "No delete candidates found"
log "Summary: packages=${PACKAGE_COUNT} versions=${TOTAL_VERSION_COUNT} kept=${KEPT_COUNT} candidates=0 deleted=0 errors=0" log "Summary: packages=${package_count} versions=${total_version_count} kept=${kept_count} candidates=0 deleted=0 errors=0"
return 0 return 0
fi fi
while IFS=$'\t' read -r name version created_at; do body_file="$(mktemp)"
[[ -z "${name}" ]] && continue headers_file="$(mktemp)"
encoded_owner="$(url_encode "${owner}")"
while IFS=$'\t' read -r package_name version _created_at; do
[[ -z "${package_name}" ]] && continue
if api_request DELETE "/api/v1/packages/${REPO_OWNER}/nuget/${name}/${version}"; then encoded_package_name="$(url_encode "${package_name}")"
log "Deleted package ${name} version ${version} -> ${API_STATUS_TEXT}" encoded_version="$(url_encode "${version}")"
: > "${body_file}"
: > "${headers_file}"
meta="$(api_request "${token}" DELETE "/api/v1/packages/${encoded_owner}/nuget/${encoded_package_name}/${encoded_version}" "${body_file}" "${headers_file}")"
IFS=$'\t' read -r http_code status_text request_id <<< "${meta}"
if [[ "${http_code}" =~ ^2 ]]; then
log "Deleted package ${package_name} version ${version} -> ${status_text}"
deleted_count=$((deleted_count + 1)) deleted_count=$((deleted_count + 1))
else else
if [[ -n "${API_REQUEST_ID}" ]]; then if [[ -n "${request_id}" ]]; then
log "ERROR: DELETE package ${name} version ${version} -> ${API_STATUS_TEXT} request_id=${API_REQUEST_ID}" log "ERROR: DELETE package ${package_name} version ${version} -> ${status_text} request_id=${request_id}"
else else
log "ERROR: DELETE package ${name} version ${version} -> ${API_STATUS_TEXT}" log "ERROR: DELETE package ${package_name} version ${version} -> ${status_text}"
fi fi
error_count=$((error_count + 1)) error_count=$((error_count + 1))
fi fi
done < "${CANDIDATES_FILE}" done < "${candidate_file}"
log "Summary: packages=${PACKAGE_COUNT} versions=${TOTAL_VERSION_COUNT} kept=${KEPT_COUNT} candidates=${CANDIDATE_COUNT} deleted=${deleted_count} errors=${error_count}" log "Summary: packages=${package_count} versions=${total_version_count} kept=${kept_count} candidates=${candidate_count} deleted=${deleted_count} errors=${error_count}"
rm -f "${body_file}" "${headers_file}"
} }
main() { main() {
local token keep_versions # Entry point for the Docker container. Resolves inputs, builds candidates,
# and applies deletes for the selected NuGet packages.
local token keep_count repository owner _repo package_names_csv
local -a package_names
local candidate_file summary package_count total_version_count kept_count candidate_count
log "Gitea Server Url: ${GITEA_SERVER_URL:-}" log "Gitea Server Url: ${GITEA_SERVER_URL:-}"
log "Gitea Repository: ${GITEA_REPOSITORY:-}" log "Gitea Repository: ${GITEA_REPOSITORY:-}"
@@ -246,28 +353,32 @@ main() {
fail "No Gitea token available, exiting" fail "No Gitea token available, exiting"
fi fi
export RESOLVED_GITEA_TOKEN="$token" repository="${GITEA_REPOSITORY:-}"
init_repo_context IFS=$'\t' read -r owner _repo <<< "$(parse_repo_context "${repository}")"
keep_versions="$(resolve_keep_versions)" keep_count="$(resolve_keep_count)"
log "keep_count=${keep_versions}"
resolve_package_names mapfile -t package_names < <(resolve_package_names)
log "package_names=${INPUT_PACKAGE_NAMES}" package_names_csv="$(IFS=,; echo "${package_names[*]}")"
log "keep_count=${keep_count}"
log "package_names=${package_names_csv}"
log "Token source resolved successfully" log "Token source resolved successfully"
CANDIDATES_FILE="$(mktemp)" candidate_file="$(mktemp)"
export CANDIDATES_FILE cleanup_candidate_file="${candidate_file}"
PACKAGE_COUNT=0 trap cleanup EXIT
TOTAL_VERSION_COUNT=0
KEPT_COUNT=0
CANDIDATE_COUNT=0
trap 'rm -f "${CANDIDATES_FILE}"' EXIT
collect_package_candidates summary="$(collect_package_candidates "${owner}" "${keep_count}" "${candidate_file}" "${token}" "${package_names[@]}")"
if (( PACKAGE_COUNT == 0 )); then IFS=$'\t' read -r package_count total_version_count kept_count candidate_count <<< "${summary}"
if (( package_count == 0 )); then
log "No matching packages found for requested package_names" log "No matching packages found for requested package_names"
fi fi
process_candidates
process_candidates "${owner}" "${candidate_file}" "${package_count}" "${total_version_count}" "${kept_count}" "${candidate_count}" "${token}"
log "Stage 4 complete" log "Stage 4 complete"
} }
main "$@" if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+473
View File
@@ -0,0 +1,473 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENTRYPOINT="${ROOT_DIR}/entrypoint.sh"
TEST_TMPDIR="$(mktemp -d)"
TEST_BIN_DIR="${TEST_TMPDIR}/bin"
CURL_MOCK_ROUTES="${TEST_TMPDIR}/curl.routes"
trap 'rm -rf -- "${TEST_TMPDIR}"' EXIT
mkdir -p "${TEST_BIN_DIR}"
: > "${CURL_MOCK_ROUTES}"
export PATH="${TEST_BIN_DIR}:${PATH}"
export CURL_MOCK_ROUTES
cat > "${TEST_BIN_DIR}/jq" <<'EOF'
#!/usr/bin/env python3
import json
import sys
import urllib.parse
def load_json_text(text):
text = text.strip()
if not text:
return None
return json.loads(text)
def read_json_file(path):
if path == "-":
return load_json_text(sys.stdin.read())
with open(path, "r", encoding="utf-8") as fh:
return load_json_text(fh.read())
args = sys.argv[1:]
expr = None
files = []
variables = {}
raw = False
compact = False
slurp = False
null_input = False
i = 0
while i < len(args):
arg = args[i]
if arg.startswith("-") and len(arg) > 1 and set(arg[1:]).issubset({"r", "n", "c", "s"}):
raw = raw or ("r" in arg[1:])
null_input = null_input or ("n" in arg[1:])
slurp = slurp or ("s" in arg[1:])
compact = compact or ("c" in arg[1:])
i += 1
continue
if arg == "--arg":
variables[args[i + 1]] = args[i + 2]
i += 3
continue
if arg == "--argjson":
variables[args[i + 1]] = json.loads(args[i + 2])
i += 3
continue
if arg.startswith("-"):
i += 1
continue
expr = arg
files = args[i + 1 :]
break
if expr is None:
raise SystemExit("jq mock requires an expression")
if slurp:
if files:
docs = [read_json_file(path) for path in files]
elif null_input:
docs = []
else:
docs = [load_json_text(sys.stdin.read())]
doc = docs
elif files:
doc = read_json_file(files[0])
else:
doc = None if null_input else load_json_text(sys.stdin.read())
def emit(text):
sys.stdout.write(text)
def emit_json(value):
sys.stdout.write(json.dumps(value, separators=(",", ":")))
if expr == "$value|@uri":
emit(urllib.parse.quote(str(variables["value"]), safe="-._~"))
elif expr == "length":
emit(str(len(doc)))
elif expr == "sort_by(.created_at, .version)[] | [.version, .created_at] | @tsv":
for item in sorted(doc, key=lambda entry: (entry.get("created_at", ""), entry.get("version", ""))):
emit(f"{item.get('version', '')}\t{item.get('created_at', '')}\n")
elif expr == "sort_by(.created_at, .version) | .[0:(length - $keep)]":
keep = int(variables["keep"])
items = sorted(doc, key=lambda entry: (entry.get("created_at", ""), entry.get("version", "")))
emit_json(items[: max(len(items) - keep, 0)])
elif expr == ".[] | [.name, .version, .created_at] | @tsv":
for item in doc:
emit(f"{item.get('name', '')}\t{item.get('version', '')}\t{item.get('created_at', '')}\n")
elif expr == ".[0] + .[1]":
if len(doc) < 2:
raise SystemExit("jq mock expected two inputs for slurp merge")
emit_json(doc[0] + doc[1])
else:
raise SystemExit(f"jq mock does not support expression: {expr}")
EOF
cat > "${TEST_BIN_DIR}/curl" <<'EOF'
#!/bin/bash
set -euo pipefail
method="GET"
headers_file=""
body_file=""
url=""
while (($#)); do
case "$1" in
-s|-S|-sS)
shift
;;
-H)
shift 2
;;
-X)
method="$2"
shift 2
;;
-D)
headers_file="$2"
shift 2
;;
-o)
body_file="$2"
shift 2
;;
-w)
shift 2
;;
--)
shift
break
;;
-*)
shift
;;
*)
url="$1"
shift
;;
esac
done
if [[ -z "${headers_file}" || -z "${body_file}" || -z "${url}" ]]; then
echo "curl mock missing required arguments" >&2
exit 1
fi
if [[ -n "${CURL_FAIL_MATCH:-}" && "${method}|${url}" == "${CURL_FAIL_MATCH}" ]]; then
echo "curl mock forced failure for ${method} ${url}" >&2
exit 7
fi
route_line="$(
awk -F $'\t' -v method="${method}" -v url="${url}" '$1 == method && $2 == url { print; exit }' "${CURL_MOCK_ROUTES}"
)"
if [[ -z "${route_line}" ]]; then
echo "curl mock missing route for ${method} ${url}" >&2
exit 1
fi
IFS=$'\t' read -r _ _ code status request_id body <<< "${route_line}"
printf 'HTTP/1.1 %s %s\r\n' "${code}" "${status}" > "${headers_file}"
if [[ -n "${request_id}" ]]; then
printf 'x-gitea-request-id: %s\r\n' "${request_id}" >> "${headers_file}"
fi
printf '\r\n' >> "${headers_file}"
printf '%s' "${body}" > "${body_file}"
printf '%s' "${code}"
EOF
chmod +x "${TEST_BIN_DIR}/jq" "${TEST_BIN_DIR}/curl"
source "${ENTRYPOINT}"
CAPTURE_STDOUT=""
CAPTURE_STDERR=""
CAPTURE_STATUS=0
assert_eq() {
local expected="$1"
local actual="$2"
local message="${3:-values differ}"
if [[ "${expected}" != "${actual}" ]]; then
printf 'ASSERTION FAILED: %s\nexpected: %q\nactual: %q\n' "${message}" "${expected}" "${actual}" >&2
return 1
fi
}
assert_contains() {
local haystack="$1"
local needle="$2"
local message="${3:-substring missing}"
if [[ "${haystack}" != *"${needle}"* ]]; then
printf 'ASSERTION FAILED: %s\nmissing: %s\ntext: %q\n' "${message}" "${needle}" "${haystack}" >&2
return 1
fi
}
capture_call() {
local stdout_file stderr_file
stdout_file="$(mktemp)"
stderr_file="$(mktemp)"
set +e
( "$@" ) > "${stdout_file}" 2> "${stderr_file}"
CAPTURE_STATUS=$?
set -e
CAPTURE_STDOUT="$(<"${stdout_file}")"
CAPTURE_STDERR="$(<"${stderr_file}")"
rm -f -- "${stdout_file}" "${stderr_file}"
}
reset_env() {
unset INPUT_KEEP_COUNT INPUT_PACKAGE_NAMES GITEA_SERVER_URL GITEA_REPOSITORY RUNNER_TOKEN PAGE_LIMIT CURL_FAIL_MATCH
: > "${CURL_MOCK_ROUTES}"
}
add_route() {
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" >> "${CURL_MOCK_ROUTES}"
}
run_test() {
local name="$1"
shift
if "$@"; then
printf 'ok - %s\n' "${name}"
else
printf 'not ok - %s\n' "${name}" >&2
return 1
fi
}
test_trim() {
capture_call trim ""
assert_eq "" "${CAPTURE_STDOUT}" "trim empty string"
capture_call trim " "
assert_eq "" "${CAPTURE_STDOUT}" "trim whitespace only"
capture_call trim " abc "
assert_eq "abc" "${CAPTURE_STDOUT}" "trim trims edges"
capture_call trim "a b"
assert_eq "a b" "${CAPTURE_STDOUT}" "trim keeps inner whitespace"
capture_call trim "abc"
assert_eq "abc" "${CAPTURE_STDOUT}" "trim keeps plain values"
}
test_url_encode() {
capture_call url_encode "a b/c?x=1&y=two"
assert_eq "a%20b%2Fc%3Fx%3D1%26y%3Dtwo" "${CAPTURE_STDOUT}" "url_encode encodes reserved characters"
capture_call url_encode "already%20encoded"
assert_eq "already%2520encoded" "${CAPTURE_STDOUT}" "url_encode encodes percent signs"
}
test_resolve_keep_count() {
reset_env
capture_call resolve_keep_count
assert_eq "2" "${CAPTURE_STDOUT}" "resolve_keep_count default"
INPUT_KEEP_COUNT=" 5 "
capture_call resolve_keep_count
assert_eq "5" "${CAPTURE_STDOUT}" "resolve_keep_count trims input"
INPUT_KEEP_COUNT="0"
capture_call resolve_keep_count
assert_eq "0" "${CAPTURE_STDOUT}" "resolve_keep_count accepts zero"
}
test_resolve_keep_count_invalid() {
local value
for value in abc -1 1.5; do
reset_env
INPUT_KEEP_COUNT="${value}"
capture_call resolve_keep_count
assert_eq "1" "${CAPTURE_STATUS}" "resolve_keep_count fails for ${value}"
assert_contains "${CAPTURE_STDERR}" "ERROR: Invalid keep_count: ${value}" "resolve_keep_count error message"
done
}
test_resolve_package_names() {
reset_env
INPUT_PACKAGE_NAMES=$' pkg-a , pkg-b\npkg-a,,pkg-c '
capture_call resolve_package_names
assert_eq $'pkg-a\npkg-b\npkg-c' "${CAPTURE_STDOUT}" "resolve_package_names trims, dedupes, and keeps order"
}
test_resolve_package_names_missing() {
reset_env
capture_call resolve_package_names
assert_eq "1" "${CAPTURE_STATUS}" "resolve_package_names fails when empty"
assert_contains "${CAPTURE_STDERR}" "ERROR: Missing PACKAGE_NAMES" "resolve_package_names missing message"
}
test_parse_repo_context() {
capture_call parse_repo_context " org/project-name "
assert_eq $'org\tproject-name' "${CAPTURE_STDOUT}" "parse_repo_context trims inputs"
capture_call parse_repo_context "owner/repo/sub"
assert_eq "1" "${CAPTURE_STATUS}" "parse_repo_context rejects nested paths"
assert_contains "${CAPTURE_STDERR}" "ERROR: Invalid GITEA_REPOSITORY: owner/repo/sub" "parse_repo_context error message"
}
test_api_request() {
reset_env
GITEA_SERVER_URL="https://gitea.example"
add_route GET "https://gitea.example/api/v1/test" 200 OK req-123 '{"ok":true}'
local body_file headers_file
body_file="$(mktemp)"
headers_file="$(mktemp)"
capture_call api_request token GET "/api/v1/test" "${body_file}" "${headers_file}"
assert_eq $'200\t200 OK\treq-123' "${CAPTURE_STDOUT}" "api_request metadata"
assert_contains "${CAPTURE_STDERR}" "GET /api/v1/test -> 200 OK request_id=req-123" "api_request log line"
assert_eq '{"ok":true}' "$(cat "${body_file}")" "api_request writes body"
assert_contains "$(cat "${headers_file}")" "x-gitea-request-id: req-123" "api_request writes headers"
export CURL_FAIL_MATCH="GET|https://gitea.example/api/v1/test"
capture_call api_request token GET "/api/v1/test" "${body_file}" "${headers_file}"
assert_eq "1" "${CAPTURE_STATUS}" "api_request propagates curl failure"
assert_contains "${CAPTURE_STDERR}" "ERROR: Request failed: GET /api/v1/test" "api_request failure message"
unset CURL_FAIL_MATCH
}
test_fetch_package_versions_404() {
reset_env
GITEA_SERVER_URL="https://gitea.example"
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/missing?page=1&limit=100" 404 "Not Found" req-404 '[]'
capture_call fetch_package_versions acme missing token
assert_eq "[]" "${CAPTURE_STDOUT}" "fetch_package_versions returns empty array for 404"
}
test_fetch_package_versions_paginated() {
reset_env
GITEA_SERVER_URL="https://gitea.example"
PAGE_LIMIT=2
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=2" 200 OK req-1 '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"1.1.0","created_at":"2024-02-01T00:00:00Z"}]'
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=2&limit=2" 200 OK req-2 '[{"name":"pkg-a","version":"1.2.0","created_at":"2024-03-01T00:00:00Z"}]'
capture_call fetch_package_versions acme pkg-a token
assert_eq '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"1.1.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"1.2.0","created_at":"2024-03-01T00:00:00Z"}]' "${CAPTURE_STDOUT}" "fetch_package_versions paginates and merges"
}
test_collect_package_candidates() {
reset_env
GITEA_SERVER_URL="https://gitea.example"
local candidate_file
candidate_file="$(mktemp)"
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=100" 200 OK req-1 '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"2.0.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"3.0.0","created_at":"2024-03-01T00:00:00Z"}]'
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-b?page=1&limit=100" 200 OK req-2 '[{"name":"pkg-b","version":"9.9.9","created_at":"2024-04-01T00:00:00Z"}]'
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-missing?page=1&limit=100" 404 "Not Found" req-3 '[]'
capture_call collect_package_candidates acme 2 "${candidate_file}" token pkg-a pkg-b pkg-missing
assert_eq $'2\t4\t3\t1' "${CAPTURE_STDOUT}" "collect_package_candidates summary"
assert_eq $'pkg-a\t1.0.0\t2024-01-01T00:00:00Z' "$(cat "${candidate_file}")" "collect_package_candidates chooses oldest version"
assert_contains "${CAPTURE_STDERR}" "No versions found for package pkg-missing" "collect_package_candidates logs missing packages"
}
test_process_candidates_empty() {
reset_env
local candidate_file
candidate_file="$(mktemp)"
capture_call process_candidates acme "${candidate_file}" 0 0 0 0 token
assert_contains "${CAPTURE_STDERR}" "No delete candidates found" "process_candidates empty file"
assert_contains "${CAPTURE_STDERR}" "Summary: packages=0 versions=0 kept=0 candidates=0 deleted=0 errors=0" "process_candidates empty summary"
}
test_process_candidates() {
reset_env
GITEA_SERVER_URL="https://gitea.example"
local candidate_file
candidate_file="$(mktemp)"
printf 'pkg-a\t1.0.0\t2024-01-01T00:00:00Z\npkg-b\t2.0.0\t2024-02-01T00:00:00Z\n' > "${candidate_file}"
add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-a/1.0.0" 204 "No Content" del-1 ''
add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-b/2.0.0" 500 "Internal Server Error" del-2 '{"error":"boom"}'
capture_call process_candidates acme "${candidate_file}" 2 4 3 2 token
assert_contains "${CAPTURE_STDERR}" "Deleted package pkg-a version 1.0.0 -> 204 No Content" "process_candidates success path"
assert_contains "${CAPTURE_STDERR}" "ERROR: DELETE package pkg-b version 2.0.0 -> 500 Internal Server Error request_id=del-2" "process_candidates failure path"
assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=2 deleted=1 errors=1" "process_candidates summary"
}
test_main_integration() {
reset_env
GITEA_SERVER_URL="https://gitea.example"
GITEA_REPOSITORY="acme/repo"
RUNNER_TOKEN="token"
INPUT_KEEP_COUNT="2"
INPUT_PACKAGE_NAMES="pkg-a,pkg-b"
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=100" 200 OK req-a '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"2.0.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"3.0.0","created_at":"2024-03-01T00:00:00Z"}]'
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-b?page=1&limit=100" 200 OK req-b '[{"name":"pkg-b","version":"9.9.9","created_at":"2024-04-01T00:00:00Z"}]'
add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-a/1.0.0" 204 "No Content" del-a ''
capture_call main
assert_contains "${CAPTURE_STDERR}" "keep_count=2" "main logs keep count"
assert_contains "${CAPTURE_STDERR}" "package_names=pkg-a,pkg-b" "main logs package names"
assert_contains "${CAPTURE_STDERR}" "Deleted package pkg-a version 1.0.0 -> 204 No Content" "main deletes old version"
assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=1 deleted=1 errors=0" "main summary"
assert_contains "${CAPTURE_STDERR}" "Stage 4 complete" "main final stage log"
}
tests=(
test_trim
test_url_encode
test_resolve_keep_count
test_resolve_keep_count_invalid
test_resolve_package_names
test_resolve_package_names_missing
test_parse_repo_context
test_api_request
test_fetch_package_versions_404
test_fetch_package_versions_paginated
test_collect_package_candidates
test_process_candidates_empty
test_process_candidates
test_main_integration
)
failures=0
for test_name in "${tests[@]}"; do
if run_test "${test_name}" "${test_name}"; then
:
else
failures=$((failures + 1))
fi
done
if (( failures > 0 )); then
printf '%s test(s) failed\n' "${failures}" >&2
exit 1
fi
printf 'all tests passed\n'