Merge pull request '版本 0.0.1' (#1) from develop into master
Reviewed-on: jiantw83/cleanup-nuget#1
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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 時腳本會安全中止並產生明確錯誤訊息。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache --no-check-certificate bash curl ca-certificates
|
||||
RUN apk add --no-cache bash curl jq ca-certificates
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# CLEANUP OLD NUGET
|
||||
|
||||
清理 Gitea NuGet 套件舊版本的 Docker Action。
|
||||
|
||||
## 功能
|
||||
|
||||
- 依序嘗試取得 Gitea API token。
|
||||
- 預設每個套件保留最新 `2` 個版本。
|
||||
- 直接刪除超出保留數量的舊版本。
|
||||
- 只處理你指定的 NuGet 套件名稱,可一次指定多個。
|
||||
- 輸出可搜尋的 log,包含 API status、request id 與 summary。
|
||||
- 每頁預設抓取 100 筆版本,可用 `PAGE_LIMIT` 調整。
|
||||
|
||||
## Token 來源順序
|
||||
|
||||
Action 會依序嘗試以下來源:
|
||||
|
||||
1. `inputs.RUNNER_TOKEN`
|
||||
2. `secrets.GITEA_TOKEN`
|
||||
3. `secrets.RUNNER_TOKEN`
|
||||
|
||||
若三者都沒有值,Action 會以非零 exit code 結束。
|
||||
|
||||
## Inputs
|
||||
|
||||
| 名稱 | 類型 | 預設值 | 說明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `RUNNER_TOKEN` | string | - | Gitea API token,優先順序最高 |
|
||||
| `KEEP_COUNT` | integer string | `2` | 每個套件要保留的最新版本數,必須是整數且 `>= 0` |
|
||||
| `PACKAGE_NAMES` | string | - | 要清理的 NuGet 套件名稱,逗號分隔,可一次指定多個 |
|
||||
|
||||
## Log 行為
|
||||
|
||||
執行時會輸出這類資訊:
|
||||
|
||||
- `Trying token from ...`
|
||||
- `Using token from ...`
|
||||
- `keep_count=...`
|
||||
- `package_names=...`
|
||||
- `GET /api/v1/... -> 200 OK`
|
||||
- `Candidate to delete: ...`
|
||||
- `Deleted package ... -> 204 No Content`
|
||||
- `Summary: packages=... versions=... kept=... candidates=... deleted=... errors=...`
|
||||
|
||||
## Workflow 範例
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
- uses: https://gitea.jsc.idv.tw/jiantw83/cleanup-nuget@${{ vars.ACTION_CLEANUP_NUGET_VERSION }}
|
||||
with:
|
||||
RUNNER_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
KEEP_COUNT: 2
|
||||
PACKAGE_NAMES: package-a,package-b
|
||||
```
|
||||
|
||||
## 相關檔案
|
||||
|
||||
- Action 定義: [`action.yaml`](action.yaml)
|
||||
- 執行腳本: [`entrypoint.sh`](entrypoint.sh)
|
||||
- 測試腳本: [`tests/entrypoint.sh`](tests/entrypoint.sh)
|
||||
|
||||
## 測試
|
||||
|
||||
直接執行:
|
||||
|
||||
```bash
|
||||
bash tests/entrypoint.sh
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# 開發階段狀態
|
||||
|
||||
此檔保留原本的階段劃分,作為驗收參考與變更紀錄。
|
||||
|
||||
## 已完成
|
||||
|
||||
- Stage 1 - Token 取得與優先順序
|
||||
- Stage 2 - 保留舊版套件數量設定
|
||||
- Stage 3 - 列出套件與版本(Gitea API)
|
||||
- Stage 4 - 刪除邏輯與 dry-run 支援
|
||||
- Stage 5 - 日誌、錯誤處理與可觀測性
|
||||
- Stage 6 - 測試與 Gitea workflow CI
|
||||
- Stage 7 - 文件更新與發行準備
|
||||
|
||||
## 驗收重點
|
||||
|
||||
- Token 來源依序嘗試 `inputs.runner_token`、`secrets.GITEA_TOKEN`、`secrets.RUNNER_TOKEN`
|
||||
- `keep_versions` 預設為 `2`,且必須為整數 `>= 0`
|
||||
- 預設為 `dry_run=true`
|
||||
- log 會包含 package 列表、候選清單、HTTP status、request id 與 summary
|
||||
- CI 會執行 `tests/entrypoint.sh`
|
||||
|
||||
## 本次更新
|
||||
|
||||
- 已修正 `main` 的暫存檔清理 trap,並將 `url_encode` 的效能建議納入排除清單。
|
||||
+7
-6
@@ -2,19 +2,20 @@ name: 'CLEANUP OLD NUGET'
|
||||
description: '清理 Nuget 沒用到的資源'
|
||||
author: 'Jeffery'
|
||||
inputs:
|
||||
runner_token:
|
||||
RUNNER_TOKEN:
|
||||
description: 'Gitea API token, highest priority source'
|
||||
required: false
|
||||
keep_versions:
|
||||
description: 'Number of recent package versions to keep'
|
||||
KEEP_COUNT:
|
||||
description: '保留的版本數量'
|
||||
required: false
|
||||
default: '2'
|
||||
PACKAGE_NAMES:
|
||||
description: '要清理的 NuGet 套件名稱,逗號分隔,可一次指定多個'
|
||||
required: true
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
env:
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
INPUT_RUNNER_TOKEN: ${{ inputs.runner_token }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RUNNER_TOKEN_SECRET: ${{ secrets.RUNNER_TOKEN }}
|
||||
RUNNER_TOKEN: ${{ inputs.RUNNER_TOKEN || secrets.GITEA_TOKEN || secrets.RUNNER_TOKEN }}
|
||||
|
||||
+344
-27
@@ -10,45 +10,341 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_token() {
|
||||
local source_name env_value
|
||||
local sources=(
|
||||
"inputs.RUNNER_TOKEN:INPUT_RUNNER_TOKEN"
|
||||
"secrets.GITEA_TOKEN:GITEA_TOKEN"
|
||||
"secrets.RUNNER_TOKEN:RUNNER_TOKEN_SECRET"
|
||||
)
|
||||
cleanup_candidate_file=""
|
||||
|
||||
for source in "${sources[@]}"; do
|
||||
source_name="${source%%:*}"
|
||||
env_value="${source#*:}"
|
||||
log "Trying token from ${source_name}"
|
||||
if [[ -n "${!env_value:-}" ]]; then
|
||||
log "Using token from ${source_name}"
|
||||
printf '%s' "${!env_value}"
|
||||
return 0
|
||||
fi
|
||||
log "Token not found in ${source_name}, trying next source"
|
||||
done
|
||||
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 the already-merged token input passed in RUNNER_TOKEN.
|
||||
log "Trying token from RUNNER_TOKEN"
|
||||
|
||||
if [[ -n "${RUNNER_TOKEN:-}" ]]; then
|
||||
log "Using token from RUNNER_TOKEN"
|
||||
printf '%s' "${RUNNER_TOKEN}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_keep_versions() {
|
||||
local raw_value="${INPUT_KEEP_VERSIONS:-2}"
|
||||
resolve_keep_count() {
|
||||
# Parse KEEP_COUNT and ensure it is a non-negative integer.
|
||||
local raw_value="${INPUT_KEEP_COUNT:-2}"
|
||||
|
||||
raw_value="$(trim "${raw_value}")"
|
||||
if [[ -z "${raw_value}" ]]; then
|
||||
raw_value="2"
|
||||
fi
|
||||
|
||||
if [[ ! "${raw_value}" =~ ^[0-9]+$ ]]; then
|
||||
fail "Invalid keep_versions: ${raw_value}"
|
||||
fail "Invalid keep_count: ${raw_value}"
|
||||
fi
|
||||
|
||||
printf '%s' "${raw_value}"
|
||||
}
|
||||
|
||||
resolve_package_names() {
|
||||
# Normalize PACKAGE_NAMES into a unique, newline-separated list.
|
||||
local raw_value="${INPUT_PACKAGE_NAMES:-}"
|
||||
local normalized token
|
||||
local -A seen=()
|
||||
local -a package_names=()
|
||||
|
||||
if [[ -z "$(trim "${raw_value}")" ]]; then
|
||||
fail "Missing PACKAGE_NAMES"
|
||||
fi
|
||||
|
||||
normalized="${raw_value//$'\n'/,}"
|
||||
IFS=',' read -r -a tokens <<< "${normalized}"
|
||||
|
||||
for token in "${tokens[@]}"; do
|
||||
token="$(trim "${token}")"
|
||||
[[ -n "${token}" ]] || continue
|
||||
|
||||
if [[ -z "${seen["${token}"]+x}" ]]; then
|
||||
seen["${token}"]=1
|
||||
package_names+=("${token}")
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#package_names[@]} == 0 )); then
|
||||
fail "Missing PACKAGE_NAMES"
|
||||
fi
|
||||
|
||||
printf '%s\n' "${package_names[@]}"
|
||||
}
|
||||
|
||||
parse_repo_context() {
|
||||
# Convert the repository slug into owner/repo parts.
|
||||
local repository="$1"
|
||||
local owner repo
|
||||
|
||||
repository="$(trim "${repository}")"
|
||||
if [[ -z "${repository}" || "${repository}" != */* ]]; then
|
||||
fail "Invalid GITEA_REPOSITORY: ${repository:-<empty>}"
|
||||
fi
|
||||
|
||||
owner="${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() {
|
||||
# Perform one HTTP request and return status metadata as TSV.
|
||||
# stdout format: http_code<TAB>status_text<TAB>request_id
|
||||
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}"
|
||||
|
||||
if ! http_code="$(
|
||||
curl -sS \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-X "${method}" \
|
||||
-D "${headers_file}" \
|
||||
-o "${body_file}" \
|
||||
-w '%{http_code}' \
|
||||
"${url}"
|
||||
)"; then
|
||||
fail "Request failed: ${method} ${path}"
|
||||
fi
|
||||
|
||||
status_line="$(head -n 1 "${headers_file}" | tr -d '\r')"
|
||||
status_text="$(printf '%s' "${status_line}" | cut -d' ' -f2-)"
|
||||
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'
|
||||
)"
|
||||
|
||||
if [[ -n "${request_id}" ]]; then
|
||||
log "${method} ${path} -> ${status_text} request_id=${request_id}"
|
||||
else
|
||||
log "${method} ${path} -> ${status_text}"
|
||||
fi
|
||||
|
||||
printf '%s\t%s\t%s\n' "${http_code}" "${status_text}" "${request_id}"
|
||||
}
|
||||
|
||||
fetch_package_versions() {
|
||||
# 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 limit="${PAGE_LIMIT:-100}"
|
||||
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)"
|
||||
page_file="$(mktemp)"
|
||||
headers_file="$(mktemp)"
|
||||
printf '[]' > "${aggregate_file}"
|
||||
|
||||
while :; do
|
||||
path="/api/v1/packages/${encoded_owner}/nuget/${encoded_package_name}?page=${page}&limit=${limit}"
|
||||
: > "${page_file}"
|
||||
: > "${headers_file}"
|
||||
meta="$(api_request "${token}" GET "${path}" "${page_file}" "${headers_file}")"
|
||||
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
|
||||
|
||||
if [[ ! "${http_code}" =~ ^2 ]]; then
|
||||
rm -f "${page_file}" "${headers_file}" "${aggregate_file}"
|
||||
fail "Unexpected response for package ${package_name}: ${status_text}"
|
||||
fi
|
||||
|
||||
page_length="$(jq 'length' "${page_file}")"
|
||||
|
||||
tmp_file="$(mktemp)"
|
||||
jq -s '.[0] + .[1]' "${aggregate_file}" "${page_file}" > "${tmp_file}"
|
||||
mv "${tmp_file}" "${aggregate_file}"
|
||||
|
||||
if (( page_length < limit )); then
|
||||
break
|
||||
fi
|
||||
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
cat "${aggregate_file}"
|
||||
rm -f "${page_file}" "${headers_file}" "${aggregate_file}"
|
||||
}
|
||||
|
||||
collect_package_candidates() {
|
||||
# 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
|
||||
|
||||
: > "${candidate_file}"
|
||||
|
||||
for package_name in "${package_names[@]}"; do
|
||||
versions_json="$(fetch_package_versions "${owner}" "${package_name}" "${token}")"
|
||||
|
||||
if [[ "$(jq 'length' <<<"${versions_json}")" -eq 0 ]]; then
|
||||
log "No versions found for package ${package_name}"
|
||||
continue
|
||||
fi
|
||||
|
||||
package_count=$((package_count + 1))
|
||||
total_versions="$(jq 'length' <<<"${versions_json}")"
|
||||
total_version_count=$((total_version_count + total_versions))
|
||||
|
||||
log "Package ${package_name}: total_versions=${total_versions} keep_count=${keep_count}"
|
||||
log "Package ${package_name} versions (oldest -> newest):"
|
||||
while IFS=$'\t' read -r version created_at; do
|
||||
[[ -z "${version}" ]] && continue
|
||||
log " - ${version} (${created_at})"
|
||||
done < <(jq -r 'sort_by(.created_at, .version)[] | [.version, .created_at] | @tsv' <<<"${versions_json}")
|
||||
|
||||
if (( total_versions <= keep_count )); then
|
||||
log " keep all ${total_versions} versions"
|
||||
kept_count=$((kept_count + total_versions))
|
||||
continue
|
||||
fi
|
||||
|
||||
kept_count=$((kept_count + keep_count))
|
||||
candidates_json="$(
|
||||
jq -c --argjson keep "${keep_count}" \
|
||||
'sort_by(.created_at, .version) | .[0:(length - $keep)]' <<<"${versions_json}"
|
||||
)"
|
||||
|
||||
while IFS=$'\t' read -r package version created_at; do
|
||||
[[ -z "${package}" ]] && continue
|
||||
log "Candidate to delete: package ${package} version ${version} (created: ${created_at})"
|
||||
printf '%s\t%s\t%s\n' "${package}" "${version}" "${created_at}" >> "${candidate_file}"
|
||||
candidate_count=$((candidate_count + 1))
|
||||
done < <(jq -r '.[] | [.name, .version, .created_at] | @tsv' <<<"${candidates_json}")
|
||||
done
|
||||
|
||||
printf '%s\t%s\t%s\t%s\n' "${package_count}" "${total_version_count}" "${kept_count}" "${candidate_count}"
|
||||
}
|
||||
|
||||
process_candidates() {
|
||||
# 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 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 "${candidate_file}" ]]; then
|
||||
log "No delete candidates found"
|
||||
log "Summary: packages=${package_count} versions=${total_version_count} kept=${kept_count} candidates=0 deleted=0 errors=0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
body_file="$(mktemp)"
|
||||
headers_file="$(mktemp)"
|
||||
encoded_owner="$(url_encode "${owner}")"
|
||||
while IFS=$'\t' read -r package_name version _created_at; do
|
||||
[[ -z "${package_name}" ]] && continue
|
||||
|
||||
encoded_package_name="$(url_encode "${package_name}")"
|
||||
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))
|
||||
else
|
||||
if [[ -n "${request_id}" ]]; then
|
||||
log "ERROR: DELETE package ${package_name} version ${version} -> ${status_text} request_id=${request_id}"
|
||||
else
|
||||
log "ERROR: DELETE package ${package_name} version ${version} -> ${status_text}"
|
||||
fi
|
||||
error_count=$((error_count + 1))
|
||||
fi
|
||||
done < "${candidate_file}"
|
||||
|
||||
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() {
|
||||
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 Repository: ${GITEA_REPOSITORY:-}"
|
||||
@@ -57,11 +353,32 @@ main() {
|
||||
fail "No Gitea token available, exiting"
|
||||
fi
|
||||
|
||||
export RESOLVED_GITEA_TOKEN="$token"
|
||||
keep_versions="$(resolve_keep_versions)"
|
||||
log "keep_versions=${keep_versions}"
|
||||
repository="${GITEA_REPOSITORY:-}"
|
||||
IFS=$'\t' read -r owner _repo <<< "$(parse_repo_context "${repository}")"
|
||||
keep_count="$(resolve_keep_count)"
|
||||
|
||||
mapfile -t package_names < <(resolve_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 "Stage 2 complete"
|
||||
|
||||
candidate_file="$(mktemp)"
|
||||
cleanup_candidate_file="${candidate_file}"
|
||||
trap cleanup EXIT
|
||||
|
||||
summary="$(collect_package_candidates "${owner}" "${keep_count}" "${candidate_file}" "${token}" "${package_names[@]}")"
|
||||
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"
|
||||
fi
|
||||
|
||||
process_candidates "${owner}" "${candidate_file}" "${package_count}" "${total_version_count}" "${kept_count}" "${candidate_count}" "${token}"
|
||||
log "Stage 4 complete"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
Executable
+473
@@ -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'
|
||||
Reference in New Issue
Block a user