From 2f9e79ec175672785c992385023b4cc0e210ace2 Mon Sep 17 00:00:00 2001 From: patrickke Date: Tue, 18 Aug 2026 14:39:45 +0800 Subject: [PATCH] feat: add Forgejo Actions run tools --- README.md | 2 +- README.zh-TW.md | 2 +- docs/admin-guide.md | 2 +- docs/admin-guide.zh-TW.md | 2 +- docs/known-limitations.md | 2 +- docs/known-limitations.zh-TW.md | 2 +- docs/tools/v1-tool-catalog.md | 21 +- docs/user-guide.md | 9 +- docs/user-guide.zh-TW.md | 9 +- .../application/forgejo_tool_service.py | 34 ++ src/forgejo_mcp/audit/redaction.py | 4 + src/forgejo_mcp/forgejo/client.py | 410 ++++++++++++++++++ src/forgejo_mcp/mcp/server.py | 53 ++- src/forgejo_mcp/tools/registry.py | 266 ++++++++++++ tests/contracts/forgejo-v16-openapi.json | 8 + tests/unit/test_audit_redaction.py | 20 + tests/unit/test_forgejo_action_tools.py | 188 ++++++++ tests/unit/test_tool_authorization.py | 8 + 18 files changed, 1021 insertions(+), 21 deletions(-) create mode 100644 tests/unit/test_forgejo_action_tools.py diff --git a/README.md b/README.md index 3f744a1..f2b5fc4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Users connect with their own scoped Forgejo personal access tokens (PATs). Admin ## What it provides -- 39 tools for repositories, organization repository creation, git trees, branches, commits, labels, milestones, Issues, pull requests, reviews, workflows, tags and releases. +- 47 tools for repositories, organization repository creation, git trees, branches, commits, labels, milestones, Issues, pull requests, reviews, Actions runs, jobs, logs and artifacts, tags and releases. - Global, user and token-level tool authorization in addition to Forgejo's own permissions. - Per-user Forgejo identity through a verified, scoped PAT. - AES-256-GCM encryption for stored PATs and show-once MCP tokens. diff --git a/README.zh-TW.md b/README.zh-TW.md index a913026..bec490e 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -10,7 +10,7 @@ Forgejo MCP 是一套自架的 [Model Context Protocol](https://modelcontextprot ## 能做什麼 -- 提供 39 個工具,涵蓋 repository、組織 repository 建立、git tree、branch、commit、label、milestone、Issue、pull request、review、workflow、tag 與 release。 +- 提供 47 個工具,涵蓋 repository、組織 repository 建立、git tree、branch、commit、label、milestone、Issue、pull request、review、Actions run、job、log、artifact、tag 與 release。 - 在 Forgejo 原有權限之外,增加全域、使用者與 token 三層工具授權。 - 使用者透過已驗證且限制權限範圍的 Forgejo PAT,以自己的 Forgejo 身分操作。 - 使用 AES-256-GCM 加密儲存 PAT,MCP token 只顯示一次。 diff --git a/docs/admin-guide.md b/docs/admin-guide.md index e8e2bba..0c595c2 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -59,7 +59,7 @@ v0.1.0 is contract-tested against Forgejo `16.0.2+gitea-1.22.0`. See [Known limi ## 3. Configure global tools -Review the 39-tool catalog and enable only the tools the organization intends to expose. Global disable is the top-level kill switch: a disabled tool is unavailable to every user and token. +Review the 47-tool catalog and enable only the tools the organization intends to expose. Global disable is the top-level kill switch: a disabled tool is unavailable to every user and token. Suggested rollout policy: diff --git a/docs/admin-guide.zh-TW.md b/docs/admin-guide.zh-TW.md index 29360fb..8eb83b8 100644 --- a/docs/admin-guide.zh-TW.md +++ b/docs/admin-guide.zh-TW.md @@ -59,7 +59,7 @@ v0.1.0 已依 Forgejo `16.0.2+gitea-1.22.0` contract 測試。連接其他版本 ## 3. 設定全域工具 -檢查 39 個工具,並只啟用組織預計提供的工具。Global disable 是最高層的 kill switch:停用後,所有使用者與 token 都無法使用該工具。 +檢查 47 個工具,並只啟用組織預計提供的工具。Global disable 是最高層的 kill switch:停用後,所有使用者與 token 都無法使用該工具。 建議逐步開放: diff --git a/docs/known-limitations.md b/docs/known-limitations.md index c4e71b3..7828b66 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -35,7 +35,7 @@ v0.1.0 is the initial open-source release. It provides the complete Forgejo deve ## Tool scope -- The catalog contains 39 workflow-oriented tools; it is not a one-to-one wrapper for every Forgejo API endpoint. +- The catalog contains 47 workflow-oriented tools; it is not a one-to-one wrapper for every Forgejo API endpoint. - Repository creation is limited to existing organizations and remains subject to the stored PAT's Forgejo permissions. - Organization administration, repository deletion, user administration inside Forgejo, SSH key management, package administration and arbitrary API passthrough are intentionally excluded. - Workflow dispatch requires Forgejo Actions and an existing workflow file in the target repository. diff --git a/docs/known-limitations.zh-TW.md b/docs/known-limitations.zh-TW.md index 20c1adb..ed2372a 100644 --- a/docs/known-limitations.zh-TW.md +++ b/docs/known-limitations.zh-TW.md @@ -35,7 +35,7 @@ v0.1.0 是第一個開源版本,已提供完整 Forgejo 開發流程與核心 ## 工具範圍 -- 目前包含 39 個以 workflow 為導向的工具,不是一對一包裝所有 Forgejo API endpoints。 +- 目前包含 47 個以 workflow 為導向的工具,不是一對一包裝所有 Forgejo API endpoints。 - Repository 建立僅支援既有組織,並受儲存的 PAT 在 Forgejo 中實際權限限制。 - Organization administration、repository deletion、Forgejo user administration、SSH key management、package administration 與 arbitrary API passthrough 明確排除。 - Workflow dispatch 需要 Forgejo Actions,以及 target repository 中既有的 workflow file。 diff --git a/docs/tools/v1-tool-catalog.md b/docs/tools/v1-tool-catalog.md index db3f148..b6d143d 100644 --- a/docs/tools/v1-tool-catalog.md +++ b/docs/tools/v1-tool-catalog.md @@ -2,11 +2,11 @@ 本文件是 Forgejo MCP **v1.0 工具功能、公開名稱及 schema 邊界的規範文件**。實作、測試、Dashboard 權限顯示與相容性判斷均以本文件為準。 -> 狀態:39 個 v1 工具已實作。公開 tool name 與已發布 schema 視為 SemVer public API。 +> 狀態:47 個 v1 工具已實作。公開 tool name 與已發布 schema 視為 SemVer public API。 ## 1. v1 範圍 -v1 提供 24 個唯讀工具及 15 個寫入工具,共 39 個。平台不提供任意 HTTP request、任意 URL 或通用 Forgejo API proxy;每個工具都必須對應 Forgejo v16-compatible OpenAPI endpoint。 +v1 提供 30 個唯讀工具及 17 個寫入工具,共 47 個。平台不提供任意 HTTP request、任意 URL 或通用 Forgejo API proxy;每個工具都必須對應 Forgejo v16-compatible OpenAPI endpoint。 | 批次 | 功能 | Tools | 狀態 | |---|---|---:|---| @@ -18,6 +18,7 @@ v1 提供 24 個唯讀工具及 15 個寫入工具,共 39 個。平台不提 | 5 | File content 與 multi-file commit | 2 | 已完成 | | 6 | Workflow dispatch、tag 與 release | 3 | 已完成 | | 7 | Git tree、label、milestone 與 PR 查詢補強 | 6 read | 已完成 | +| 8 | Actions run、job、log、artifact、cancel 與 delete | 6 read + 2 write | 已完成 | ## 2. 共通契約 @@ -475,16 +476,24 @@ Forgejo v16 compare response 不提供可靠的 ahead/behind 或 resolved base/h Review event 限定 `APPROVED`、`REQUEST_CHANGES` 或 `COMMENT`,可包含最多 100 個 inline comments。Merge 支援 Forgejo OpenAPI 宣告的 merge、squash、rebase、rebase-merge 與 manually-merged strategy。 -### 5.8 Commit status、workflow、tag 與 release +### 5.8 Commit status、Actions、tag 與 release | Tool | Forgejo endpoint | |---|---| | `forgejo_get_commit_status` | `GET /repos/{owner}/{repo}/commits/{ref}/status` | +| `forgejo_list_action_runs` | `GET /repos/{owner}/{repo}/actions/runs` | +| `forgejo_get_action_run` | `GET /repos/{owner}/{repo}/actions/runs/{run_id}` | +| `forgejo_list_action_run_jobs` | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | +| `forgejo_get_action_job_log` | `GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs` | +| `forgejo_get_action_run_logs` | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs` | +| `forgejo_list_action_run_artifacts` | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | +| `forgejo_cancel_action_run` | `POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | +| `forgejo_delete_action_run` | `DELETE /repos/{owner}/{repo}/actions/runs/{run_id}` | | `forgejo_dispatch_workflow` | `POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches` | | `forgejo_create_tag` | `POST /repos/{owner}/{repo}/tags` | | `forgejo_create_release` | `POST /repos/{owner}/{repo}/releases` | -Workflow 工具只提供 Forgejo v16-compatible OpenAPI 已確認的 `workflow_dispatch`;run、job、log 與 rerun 工具須在最低支援版本 Swagger 確認後才能新增。 +Run 與 artifact 清單皆有界;job 清單最多回傳 100 筆。Job log 最多回傳 1 MiB UTF-8 文字並提供原始大小、SHA-256 與 `truncated`。Run log ZIP 最大接受 10 MiB,最多解開 100 個檔案且合計最多回傳 1 MiB 文字;不下載 artifact 內容。`forgejo_delete_action_run` 只適用於 Forgejo 允許刪除的已完成 run。Forgejo v16 沒有公開 PAT REST rerun endpoint,因此不提供 rerun 工具。 ## 6. Audit 規格 @@ -515,7 +524,7 @@ v1 不實作也不在 registry 中預留以下工具: - Update/delete repository;建立 repository 僅限既有組織,且只能透過 `forgejo_create_organization_repository`。 - Protected branch、collaborator、team、organization 權限管理。 - Webhook、deploy key、GPG key、OAuth application 管理。 -- 未由最低支援 Forgejo OpenAPI 確認的 Actions run/job/log/rerun,以及 runner、package、secret 或 variable 管理。 +- Actions rerun,以及 runner、package、secret 或 variable 管理。 - Generic Forgejo API request/proxy。 - 任意 URL fetch 或讓 argument 覆寫 Forgejo base URL。 @@ -547,7 +556,7 @@ v1 不實作也不在 registry 中預留以下工具: 最低支援版本鎖定為 Forgejo `16.0.2+gitea-1.22.0`,測試 image 固定使用 `codeberg.org/forgejo/forgejo:16.0.2-rootless`。 -- `tests/contracts/forgejo-v16-openapi.json` 保存 39 個 MCP tools 對應的 method、path、operation ID,以及完整 `/swagger.v1.json` SHA-256。 +- `tests/contracts/forgejo-v16-openapi.json` 保存 47 個 MCP tools 對應的 method、path、operation ID,以及完整 `/swagger.v1.json` SHA-256。 - `scripts/verify_forgejo_openapi.py` 驗證實際 instance 的版本、checksum、registry 完整性與每個 operation。 - `scripts/test-full-docker-e2e.sh` 在每次 CI 以 pinned image 執行 contract verification 及完整 MCP development flow。 - 正式發布前仍須加入最低與最新支援版本的 integration matrix。 diff --git a/docs/user-guide.md b/docs/user-guide.md index bdcd3f9..391c0ba 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -109,15 +109,16 @@ Depending on your grants, an MCP client can: 4. commit multiple file changes atomically; 5. create a pull request and inspect its commits, diff and changed files; 6. request reviewers and submit, list or load a specific review; -7. inspect commit status and dispatch a workflow; -8. check whether a pull request has already been merged, or merge it; -9. create a tag and release. +7. inspect commit status, dispatch a workflow, and inspect Actions runs, jobs, logs and artifacts; +8. cancel a running Action or delete a completed Action run; +9. check whether a pull request has already been merged, or merge it; +10. create a tag and release. Write tools change the real Forgejo repository. Review the tool name, repository, branch and proposed arguments before approving a client action. `forgejo_get_pull_request_merge_status` reports only whether a pull request has already been merged; use the `mergeable` field from `forgejo_get_pull_request` to check whether it can be merged. If a write tool times out or the connection is interrupted, do not retry immediately. First use a read tool to check whether the Issue, commit, pull request, merge, tag or release was created, avoiding duplicate side effects. -The complete schema and behavior of all 39 tools are documented in the [v1 tool catalog](tools/v1-tool-catalog.md). +The complete schema and behavior of all 47 tools are documented in the [v1 tool catalog](tools/v1-tool-catalog.md). ## Token and credential maintenance diff --git a/docs/user-guide.zh-TW.md b/docs/user-guide.zh-TW.md index eedd121..2dc1ed2 100644 --- a/docs/user-guide.zh-TW.md +++ b/docs/user-guide.zh-TW.md @@ -109,15 +109,16 @@ Authorization: Bearer fmcp_... 4. 以單一 commit 變更多個檔案; 5. 建立 pull request,並檢視其中的 commits、diff 與 changed files; 6. 要求 reviewer,並提交、列出或讀取特定 review; -7. 檢視 commit status 並 dispatch workflow; -8. 確認 pull request 是否已合併,或執行 merge; -9. 建立 tag 與 release。 +7. 檢視 commit status、dispatch workflow,並查看 Actions run、job、log 與 artifact; +8. 取消執行中的 Action,或刪除已完成的 Action run; +9. 確認 pull request 是否已合併,或執行 merge; +10. 建立 tag 與 release。 Write tools 會直接修改真實 Forgejo repository。允許 client 執行前,請檢查工具名稱、repository、branch 與 proposed arguments。`forgejo_get_pull_request_merge_status` 只表示 PR 是否已經合併;是否可合併請查看 `forgejo_get_pull_request` 的 `mergeable`。 若 write tool 發生 timeout 或連線中斷,不要立刻重試;請先使用 read tool 確認 Issue、commit、PR、merge、tag 或 release 是否已建立,避免重複操作。 -全部 39 個工具的 schema 與行為請參閱 [v1 工具目錄](tools/v1-tool-catalog.md)。 +全部 47 個工具的 schema 與行為請參閱 [v1 工具目錄](tools/v1-tool-catalog.md)。 ## Token 與 credential 維護 diff --git a/src/forgejo_mcp/application/forgejo_tool_service.py b/src/forgejo_mcp/application/forgejo_tool_service.py index 1b5a9cd..b842294 100644 --- a/src/forgejo_mcp/application/forgejo_tool_service.py +++ b/src/forgejo_mcp/application/forgejo_tool_service.py @@ -285,6 +285,40 @@ async def get_pull_request_merge_status(self, user_id: uuid.UUID, **kwargs: Any) async def get_commit_status(self, user_id: uuid.UUID, **kwargs: Any) -> dict[str, Any]: return cast(dict[str, Any], await self._call(user_id, "get_commit_status", **kwargs)) + async def list_action_runs(self, user_id: uuid.UUID, **kwargs: Any) -> dict[str, Any]: + return cast(dict[str, Any], await self._call(user_id, "list_action_runs", **kwargs)) + + async def get_action_run(self, user_id: uuid.UUID, **kwargs: Any) -> dict[str, Any]: + return cast(dict[str, Any], await self._call(user_id, "get_action_run", **kwargs)) + + async def list_action_run_jobs( + self, user_id: uuid.UUID, **kwargs: Any + ) -> BoundedList[dict[str, Any]]: + return cast( + BoundedList[dict[str, Any]], + await self._call(user_id, "list_action_run_jobs", **kwargs), + ) + + async def get_action_job_log(self, user_id: uuid.UUID, **kwargs: Any) -> dict[str, Any]: + return cast(dict[str, Any], await self._call(user_id, "get_action_job_log", **kwargs)) + + async def get_action_run_logs(self, user_id: uuid.UUID, **kwargs: Any) -> dict[str, Any]: + return cast(dict[str, Any], await self._call(user_id, "get_action_run_logs", **kwargs)) + + async def list_action_run_artifacts( + self, user_id: uuid.UUID, **kwargs: Any + ) -> Page[dict[str, Any]]: + return cast( + Page[dict[str, Any]], + await self._call(user_id, "list_action_run_artifacts", **kwargs), + ) + + async def cancel_action_run(self, user_id: uuid.UUID, **kwargs: Any) -> None: + await self._call(user_id, "cancel_action_run", **kwargs) + + async def delete_action_run(self, user_id: uuid.UUID, **kwargs: Any) -> None: + await self._call(user_id, "delete_action_run", **kwargs) + async def dispatch_workflow(self, user_id: uuid.UUID, **kwargs: Any) -> None: await self._call(user_id, "dispatch_workflow", **kwargs) diff --git a/src/forgejo_mcp/audit/redaction.py b/src/forgejo_mcp/audit/redaction.py index d14ef3c..431e17f 100644 --- a/src/forgejo_mcp/audit/redaction.py +++ b/src/forgejo_mcp/audit/redaction.py @@ -16,6 +16,10 @@ "owner", "repo", "number", + "run_id", + "job_id", + "attempt", + "workflow_id", "path", "ref", "sha", diff --git a/src/forgejo_mcp/forgejo/client.py b/src/forgejo_mcp/forgejo/client.py index 97f775e..c38d757 100644 --- a/src/forgejo_mcp/forgejo/client.py +++ b/src/forgejo_mcp/forgejo/client.py @@ -2,7 +2,9 @@ import base64 import binascii import hashlib +import io import time +import zipfile from dataclasses import dataclass from datetime import UTC, datetime from email.utils import parsedate_to_datetime @@ -52,6 +54,9 @@ MAX_JSON_RESPONSE_BYTES = 2 * 1024 * 1024 MAX_FILE_CONTENT_BYTES = 1024 * 1024 MAX_DIFF_BYTES = 2 * 1024 * 1024 +MAX_ACTION_LOG_BYTES = 1024 * 1024 +MAX_ACTION_LOG_ARCHIVE_BYTES = 10 * 1024 * 1024 +MAX_ACTION_LOG_FILES = 100 @dataclass(frozen=True) @@ -113,6 +118,17 @@ def normalize_base_url(value: str) -> str: return urlunsplit(normalized) +_ACTION_STATUS_VALUES = { + "unknown", + "waiting", + "running", + "success", + "failure", + "cancelled", + "skipped", + "blocked", +} + _REPOSITORY_ORDER_VALUES = { "name", "id", @@ -1043,6 +1059,303 @@ async def get_commit_status( ) return _combined_status(payload) + async def list_action_runs( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + event: list[str] | None, + status: list[str] | None, + workflow_id: str | None, + run_number: int | None, + head_sha: str | None, + ref: str | None, + page: int, + limit: int, + ) -> dict[str, Any]: + _validate_page(page, limit) + params: dict[str, Any] = {"page": page, "limit": limit} + if event is not None: + params["event"] = _string_list(event, "action events", 20) + if status is not None: + statuses = _string_list(status, "action statuses", 20) + if not set(statuses) <= _ACTION_STATUS_VALUES: + raise ValidationFailed("action status is invalid") + params["status"] = statuses + for key, value in { + "workflow_id": workflow_id, + "head_sha": head_sha, + "ref": ref, + }.items(): + if value is not None: + params[key] = _ref_value(value, key.replace("_", " ")) + if run_number is not None: + params["run_number"] = _positive_id(run_number, "run number") + payload = await self._get_json( + endpoint=self._repo_endpoint(base_url, owner, repo, "actions/runs"), + token=token, + verify_tls=verify_tls, + params=params, + resource="action run list", + ) + if not isinstance(payload, dict) or not isinstance(payload.get("workflow_runs"), list): + raise ExternalServiceUnavailable("Forgejo returned an invalid action run list") + runs = payload["workflow_runs"] + if not all(isinstance(item, dict) for item in runs): + raise ExternalServiceUnavailable("Forgejo returned an invalid action run list") + total_count = payload.get("total_count", len(runs)) + if not isinstance(total_count, int) or isinstance(total_count, bool) or total_count < 0: + raise ExternalServiceUnavailable("Forgejo returned an invalid action run list") + return { + "items": [_action_run_summary(item) for item in runs], + "page": page, + "limit": limit, + "has_more": page * limit < total_count, + "total_count": total_count, + } + + async def get_action_run( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + run_id: int, + ) -> dict[str, Any]: + payload = await self._get_json( + endpoint=self._repo_endpoint( + base_url, owner, repo, f"actions/runs/{_positive_id(run_id, 'run ID')}" + ), + token=token, + verify_tls=verify_tls, + params=None, + resource="action run", + ) + return _action_run_summary(payload) + + async def list_action_run_jobs( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + run_id: int, + ) -> BoundedList[dict[str, Any]]: + payload = await self._get_json( + endpoint=self._repo_endpoint( + base_url, + owner, + repo, + f"actions/runs/{_positive_id(run_id, 'run ID')}/jobs", + ), + token=token, + verify_tls=verify_tls, + params=None, + resource="action run jobs", + ) + if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): + raise ExternalServiceUnavailable("Forgejo returned invalid action run jobs") + return BoundedList( + items=[_action_job_summary(item) for item in payload[:100]], + truncated=len(payload) > 100, + ) + + async def get_action_job_log( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + job_id: int, + attempt: int | None, + ) -> dict[str, Any]: + params = {"attempt": _positive_id(attempt, "attempt")} if attempt is not None else None + response = await self._request( + method="GET", + endpoint=self._repo_endpoint( + base_url, owner, repo, f"actions/jobs/{_positive_id(job_id, 'job ID')}/logs" + ), + token=token, + verify_tls=verify_tls, + params=params, + json_body=None, + resource="action job log", + expected_status={200, 206}, + accept="text/plain", + ) + content = response.content + bounded = content[:MAX_ACTION_LOG_BYTES] + return { + "job_id": job_id, + "attempt": attempt, + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "content": bounded.decode("utf-8", errors="replace"), + "truncated": len(content) > len(bounded), + } + + async def get_action_run_logs( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + run_id: int, + ) -> dict[str, Any]: + response = await self._request( + method="GET", + endpoint=self._repo_endpoint( + base_url, + owner, + repo, + f"actions/runs/{_positive_id(run_id, 'run ID')}/logs", + ), + token=token, + verify_tls=verify_tls, + params=None, + json_body=None, + resource="action run logs", + expected_status=200, + accept="application/zip", + ) + archive = response.content + if len(archive) > MAX_ACTION_LOG_ARCHIVE_BYTES: + raise ExternalServiceUnavailable("Forgejo action run logs response is too large") + files: list[dict[str, Any]] = [] + remaining = MAX_ACTION_LOG_BYTES + files_truncated = False + try: + with zipfile.ZipFile(io.BytesIO(archive)) as bundle: + entries = [entry for entry in bundle.infolist() if not entry.is_dir()] + for entry in entries[:MAX_ACTION_LOG_FILES]: + if entry.file_size > MAX_ACTION_LOG_ARCHIVE_BYTES: + raise ExternalServiceUnavailable( + "Forgejo action run logs contain an oversized file" + ) + content = bundle.read(entry) + bounded = content[:remaining] + files.append( + { + "name": entry.filename, + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "content": bounded.decode("utf-8", errors="replace"), + "truncated": len(content) > len(bounded), + } + ) + remaining -= len(bounded) + if remaining == 0: + files_truncated = len(entries) > len(files) or len(content) > len(bounded) + break + files_truncated = files_truncated or len(entries) > MAX_ACTION_LOG_FILES + except (zipfile.BadZipFile, RuntimeError, OSError) as error: + raise ExternalServiceUnavailable( + "Forgejo returned an invalid action run logs archive" + ) from error + return { + "run_id": run_id, + "size": len(archive), + "sha256": hashlib.sha256(archive).hexdigest(), + "files": files, + "files_truncated": files_truncated, + } + + async def list_action_run_artifacts( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + run_id: int, + name: str | None, + page: int, + limit: int, + ) -> Page[dict[str, Any]]: + _validate_page(page, limit) + params: dict[str, Any] = {"page": page, "limit": limit} + if name is not None: + params["name"] = _ref_value(name, "artifact name") + payload = await self._get_json( + endpoint=self._repo_endpoint( + base_url, + owner, + repo, + f"actions/runs/{_positive_id(run_id, 'run ID')}/artifacts", + ), + token=token, + verify_tls=verify_tls, + params=params, + resource="action run artifacts", + ) + if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): + raise ExternalServiceUnavailable("Forgejo returned invalid action run artifacts") + items = [_action_artifact_summary(item) for item in payload] + return Page(items=items, page=page, limit=limit, has_more=len(items) == limit) + + async def cancel_action_run( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + run_id: int, + ) -> None: + await self._request( + method="POST", + endpoint=self._repo_endpoint( + base_url, + owner, + repo, + f"actions/runs/{_positive_id(run_id, 'run ID')}/cancel", + ), + token=token, + verify_tls=verify_tls, + params=None, + json_body=None, + resource="action run cancellation", + expected_status=204, + accept="application/json", + ) + + async def delete_action_run( + self, + *, + base_url: str, + token: str, + verify_tls: bool, + owner: str, + repo: str, + run_id: int, + ) -> None: + await self._request( + method="DELETE", + endpoint=self._repo_endpoint( + base_url, owner, repo, f"actions/runs/{_positive_id(run_id, 'run ID')}" + ), + token=token, + verify_tls=verify_tls, + params=None, + json_body=None, + resource="action run deletion", + expected_status=204, + accept="application/json", + ) + async def dispatch_workflow( self, *, @@ -1734,6 +2047,103 @@ def _combined_status(payload: Any) -> dict[str, Any]: } +def _action_run_summary(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ExternalServiceUnavailable("Forgejo returned an invalid action run") + run_id = payload.get("id") + if not isinstance(run_id, int) or isinstance(run_id, bool) or run_id < 1: + raise ExternalServiceUnavailable("Forgejo returned an invalid action run") + + def optional_int(*keys: str) -> int | None: + value = next((payload[key] for key in keys if key in payload), None) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + def optional_str(*keys: str) -> str | None: + value = next((payload[key] for key in keys if key in payload), None) + return value if isinstance(value, str) else None + + workflow_id = payload.get("workflow_id") + if not isinstance(workflow_id, (str, int)) or isinstance(workflow_id, bool): + workflow_id = None + return { + "id": run_id, + "run_number": optional_int("run_number", "index_in_repo"), + "name": optional_str("name", "title"), + "event": optional_str("event", "trigger_event"), + "status": optional_str("status"), + "workflow_id": workflow_id, + "head_sha": optional_str("head_sha", "commit_sha"), + "ref": optional_str("ref", "prettyref"), + "html_url": optional_str("html_url"), + "created_at": optional_str("created_at", "created"), + "started_at": optional_str("started_at", "started"), + "completed_at": optional_str("completed_at", "stopped"), + "updated_at": optional_str("updated_at", "updated"), + "duration": optional_int("duration"), + } + + +def _action_job_summary(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ExternalServiceUnavailable("Forgejo returned an invalid action job") + job_id = payload.get("id") + if not isinstance(job_id, int) or isinstance(job_id, bool) or job_id < 1: + raise ExternalServiceUnavailable("Forgejo returned an invalid action job") + + def nullable(key: str, kind: type[Any]) -> Any: + value = payload.get(key) + return value if isinstance(value, kind) and not isinstance(value, bool) else None + + def string_list(key: str) -> list[str]: + value = payload.get(key) + return ( + value + if isinstance(value, list) and all(isinstance(item, str) for item in value) + else [] + ) + + return { + "id": job_id, + "run_id": nullable("run_id", int), + "name": nullable("name", str), + "status": nullable("status", str), + "attempt": nullable("attempt", int), + "runner_labels": string_list("runs_on"), + "needs": string_list("needs"), + "started_at": nullable("started_at", str), + "completed_at": nullable("completed_at", str), + } + + +def _action_artifact_summary(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ExternalServiceUnavailable("Forgejo returned an invalid action artifact") + artifact_id = payload.get("id") + if not isinstance(artifact_id, int) or isinstance(artifact_id, bool) or artifact_id < 1: + raise ExternalServiceUnavailable("Forgejo returned an invalid action artifact") + size = payload.get("size_in_bytes") + if not isinstance(size, int) or isinstance(size, bool) or size < 0: + size = None + return { + "id": artifact_id, + "run_id": payload.get("run_id") + if isinstance(payload.get("run_id"), int) and not isinstance(payload.get("run_id"), bool) + else None, + "name": payload.get("name") if isinstance(payload.get("name"), str) else None, + "size_in_bytes": size, + "expired": bool(payload.get("expired", False)), + "created_at": payload.get("created_at") + if isinstance(payload.get("created_at"), str) + else None, + "expires_at": payload.get("expires_at") + if isinstance(payload.get("expires_at"), str) + else None, + "updated_at": payload.get("updated_at") + if isinstance(payload.get("updated_at"), str) + else None, + } + + def _tag_summary(payload: Any) -> dict[str, Any]: if not isinstance(payload, dict): raise ExternalServiceUnavailable("Forgejo returned an invalid tag response") diff --git a/src/forgejo_mcp/mcp/server.py b/src/forgejo_mcp/mcp/server.py index 0819af0..5d49a97 100644 --- a/src/forgejo_mcp/mcp/server.py +++ b/src/forgejo_mcp/mcp/server.py @@ -507,6 +507,54 @@ async def _execute_tool( return {"number": number, "merged": merged} if name == "forgejo_get_commit_status": return await tools.get_commit_status(user_id, **common, ref=cast(str, arguments["ref"])) + if name == "forgejo_list_action_runs": + return await tools.list_action_runs( + user_id, + **common, + event=cast(list[str] | None, arguments.get("event")), + status=cast(list[str] | None, arguments.get("status")), + workflow_id=cast(str | None, arguments.get("workflow_id")), + run_number=cast(int | None, arguments.get("run_number")), + head_sha=cast(str | None, arguments.get("head_sha")), + ref=cast(str | None, arguments.get("ref")), + page=cast(int, arguments.get("page", 1)), + limit=cast(int, arguments.get("limit", 30)), + ) + if name == "forgejo_get_action_run": + return await tools.get_action_run(user_id, **common, run_id=cast(int, arguments["run_id"])) + if name == "forgejo_list_action_run_jobs": + jobs = await tools.list_action_run_jobs( + user_id, **common, run_id=cast(int, arguments["run_id"]) + ) + return {"items": jobs.items, "truncated": jobs.truncated} + if name == "forgejo_get_action_job_log": + return await tools.get_action_job_log( + user_id, + **common, + job_id=cast(int, arguments["job_id"]), + attempt=cast(int | None, arguments.get("attempt")), + ) + if name == "forgejo_get_action_run_logs": + return await tools.get_action_run_logs( + user_id, **common, run_id=cast(int, arguments["run_id"]) + ) + if name == "forgejo_list_action_run_artifacts": + return _page_result( + await tools.list_action_run_artifacts( + user_id, + **common, + run_id=cast(int, arguments["run_id"]), + name=cast(str | None, arguments.get("name")), + page=cast(int, arguments.get("page", 1)), + limit=cast(int, arguments.get("limit", 30)), + ) + ) + if name == "forgejo_cancel_action_run": + await tools.cancel_action_run(user_id, **common, run_id=cast(int, arguments["run_id"])) + return {"cancelled": True, "audit_event_id": audit_event_id} + if name == "forgejo_delete_action_run": + await tools.delete_action_run(user_id, **common, run_id=cast(int, arguments["run_id"])) + return {"deleted": True, "audit_event_id": audit_event_id} if name == "forgejo_dispatch_workflow": await tools.dispatch_workflow( user_id, @@ -607,7 +655,10 @@ def _safe_error_message(error: Exception) -> str: def _page_result(result: Any) -> dict[str, Any]: return { - "items": [item.model_dump(mode="json") for item in result.items], + "items": [ + item.model_dump(mode="json") if hasattr(item, "model_dump") else item + for item in result.items + ], "page": result.page, "limit": result.limit, "has_more": result.has_more, diff --git a/src/forgejo_mcp/tools/registry.py b/src/forgejo_mcp/tools/registry.py index 00e3218..34eefac 100644 --- a/src/forgejo_mcp/tools/registry.py +++ b/src/forgejo_mcp/tools/registry.py @@ -359,6 +359,107 @@ def _page_schema(item_schema: dict[str, Any]) -> dict[str, Any]: "maxItems": 20, } _AUDIT = {"type": "string"} +_ACTION_RUN_SCHEMA = _object_schema( + { + "id": _NUMBER, + "run_number": {"type": ["integer", "null"], "minimum": 1}, + "name": {"type": ["string", "null"]}, + "event": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"]}, + "workflow_id": {"type": ["string", "integer", "null"]}, + "head_sha": {"type": ["string", "null"]}, + "ref": {"type": ["string", "null"]}, + "html_url": {"type": ["string", "null"]}, + "created_at": {"type": ["string", "null"]}, + "started_at": {"type": ["string", "null"]}, + "completed_at": {"type": ["string", "null"]}, + "updated_at": {"type": ["string", "null"]}, + "duration": {"type": ["integer", "null"], "minimum": 0}, + }, + [ + "id", + "run_number", + "name", + "event", + "status", + "workflow_id", + "head_sha", + "ref", + "html_url", + "created_at", + "started_at", + "completed_at", + "updated_at", + "duration", + ], +) +_ACTION_JOB_SCHEMA = _object_schema( + { + "id": _NUMBER, + "run_id": {"type": ["integer", "null"], "minimum": 1}, + "name": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"]}, + "attempt": {"type": ["integer", "null"], "minimum": 1}, + "runner_labels": {"type": "array", "items": {"type": "string"}}, + "needs": {"type": "array", "items": {"type": "string"}}, + "started_at": {"type": ["string", "null"]}, + "completed_at": {"type": ["string", "null"]}, + }, + [ + "id", + "run_id", + "name", + "status", + "attempt", + "runner_labels", + "needs", + "started_at", + "completed_at", + ], +) +_ACTION_ARTIFACT_SCHEMA = _object_schema( + { + "id": _NUMBER, + "run_id": {"type": ["integer", "null"], "minimum": 1}, + "name": {"type": ["string", "null"]}, + "size_in_bytes": {"type": ["integer", "null"], "minimum": 0}, + "expired": {"type": "boolean"}, + "created_at": {"type": ["string", "null"]}, + "expires_at": {"type": ["string", "null"]}, + "updated_at": {"type": ["string", "null"]}, + }, + [ + "id", + "run_id", + "name", + "size_in_bytes", + "expired", + "created_at", + "expires_at", + "updated_at", + ], +) +_ACTION_LOG_SCHEMA = _object_schema( + { + "job_id": _NUMBER, + "attempt": {"type": ["integer", "null"], "minimum": 1}, + "size": {"type": "integer", "minimum": 0}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "content": {"type": "string"}, + "truncated": {"type": "boolean"}, + }, + ["job_id", "attempt", "size", "sha256", "content", "truncated"], +) +_ACTION_LOG_FILE_SCHEMA = _object_schema( + { + "name": {"type": "string"}, + "size": {"type": "integer", "minimum": 0}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "content": {"type": "string"}, + "truncated": {"type": "boolean"}, + }, + ["name", "size", "sha256", "content", "truncated"], +) _TOOL_SPECS = ( ToolSpec( @@ -1153,6 +1254,171 @@ def _page_schema(item_schema: dict[str, Any]) -> dict[str, Any]: ["sha", "state", "total_count", "statuses", "truncated"], ), ), + ToolSpec( + name="forgejo_list_action_runs", + title="List action runs", + description="List repository action runs with bounded pagination and optional filters.", + risk="read", + input_schema=_object_schema( + { + "owner": _OWNER, + "repo": _REPO, + "event": { + "type": "array", + "items": _REF, + "maxItems": 20, + }, + "status": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "unknown", + "waiting", + "running", + "success", + "failure", + "cancelled", + "skipped", + "blocked", + ], + }, + "maxItems": 20, + }, + "workflow_id": _REF, + "run_number": _NUMBER, + "head_sha": _REF, + "ref": _REF, + "page": _PAGE, + "limit": _LIMIT, + }, + ["owner", "repo"], + ), + output_schema=_object_schema( + { + "items": {"type": "array", "items": _ACTION_RUN_SCHEMA}, + "page": _PAGE, + "limit": _LIMIT, + "has_more": {"type": "boolean"}, + "total_count": {"type": "integer", "minimum": 0}, + }, + ["items", "page", "limit", "has_more", "total_count"], + ), + ), + ToolSpec( + name="forgejo_get_action_run", + title="Get action run", + description="Return normalized details for one repository action run.", + risk="read", + input_schema=_object_schema( + {"owner": _OWNER, "repo": _REPO, "run_id": _NUMBER}, + ["owner", "repo", "run_id"], + ), + output_schema=_ACTION_RUN_SCHEMA, + ), + ToolSpec( + name="forgejo_list_action_run_jobs", + title="List action run jobs", + description="List up to 100 jobs belonging to an action run.", + risk="read", + input_schema=_object_schema( + {"owner": _OWNER, "repo": _REPO, "run_id": _NUMBER}, + ["owner", "repo", "run_id"], + ), + output_schema=_object_schema( + { + "items": {"type": "array", "items": _ACTION_JOB_SCHEMA, "maxItems": 100}, + "truncated": {"type": "boolean"}, + }, + ["items", "truncated"], + ), + ), + ToolSpec( + name="forgejo_get_action_job_log", + title="Get action job log", + description="Return up to 1 MiB of plaintext log content for an action job attempt.", + risk="read-sensitive", + input_schema=_object_schema( + { + "owner": _OWNER, + "repo": _REPO, + "job_id": _NUMBER, + "attempt": _NUMBER, + }, + ["owner", "repo", "job_id"], + ), + output_schema=_ACTION_LOG_SCHEMA, + ), + ToolSpec( + name="forgejo_get_action_run_logs", + title="Get action run logs", + description="Return bounded plaintext files extracted from an action run log archive.", + risk="read-sensitive", + input_schema=_object_schema( + {"owner": _OWNER, "repo": _REPO, "run_id": _NUMBER}, + ["owner", "repo", "run_id"], + ), + output_schema=_object_schema( + { + "run_id": _NUMBER, + "size": {"type": "integer", "minimum": 0}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "files": { + "type": "array", + "items": _ACTION_LOG_FILE_SCHEMA, + "maxItems": 100, + }, + "files_truncated": {"type": "boolean"}, + }, + ["run_id", "size", "sha256", "files", "files_truncated"], + ), + ), + ToolSpec( + name="forgejo_list_action_run_artifacts", + title="List action run artifacts", + description="List metadata for artifacts produced by an action run.", + risk="read", + input_schema=_object_schema( + { + "owner": _OWNER, + "repo": _REPO, + "run_id": _NUMBER, + "name": _REF, + "page": _PAGE, + "limit": _LIMIT, + }, + ["owner", "repo", "run_id"], + ), + output_schema=_page_schema(_ACTION_ARTIFACT_SCHEMA), + ), + ToolSpec( + name="forgejo_cancel_action_run", + title="Cancel action run", + description="Cancel a pending or running repository action run.", + risk="write", + input_schema=_object_schema( + {"owner": _OWNER, "repo": _REPO, "run_id": _NUMBER}, + ["owner", "repo", "run_id"], + ), + output_schema=_object_schema( + {"cancelled": {"const": True}, "audit_event_id": _AUDIT}, + ["cancelled", "audit_event_id"], + ), + ), + ToolSpec( + name="forgejo_delete_action_run", + title="Delete action run", + description="Permanently delete a completed repository action run.", + risk="write", + input_schema=_object_schema( + {"owner": _OWNER, "repo": _REPO, "run_id": _NUMBER}, + ["owner", "repo", "run_id"], + ), + output_schema=_object_schema( + {"deleted": {"const": True}, "audit_event_id": _AUDIT}, + ["deleted", "audit_event_id"], + ), + ), ToolSpec( name="forgejo_dispatch_workflow", title="Dispatch workflow", diff --git a/tests/contracts/forgejo-v16-openapi.json b/tests/contracts/forgejo-v16-openapi.json index 2b1d6c7..4157763 100644 --- a/tests/contracts/forgejo-v16-openapi.json +++ b/tests/contracts/forgejo-v16-openapi.json @@ -39,6 +39,14 @@ "forgejo_merge_pull_request": {"method": "post", "path": "/repos/{owner}/{repo}/pulls/{index}/merge", "operation_id": "repoMergePullRequest"}, "forgejo_get_pull_request_merge_status": {"method": "get", "path": "/repos/{owner}/{repo}/pulls/{index}/merge", "operation_id": "repoPullRequestIsMerged"}, "forgejo_get_commit_status": {"method": "get", "path": "/repos/{owner}/{repo}/commits/{ref}/status", "operation_id": "repoGetCombinedStatusByRef"}, + "forgejo_list_action_runs": {"method": "get", "path": "/repos/{owner}/{repo}/actions/runs", "operation_id": "ListActionRuns"}, + "forgejo_get_action_run": {"method": "get", "path": "/repos/{owner}/{repo}/actions/runs/{run_id}", "operation_id": "ActionRun"}, + "forgejo_list_action_run_jobs": {"method": "get", "path": "/repos/{owner}/{repo}/actions/runs/{run_id}/jobs", "operation_id": "ListActionRunJobs"}, + "forgejo_get_action_job_log": {"method": "get", "path": "/repos/{owner}/{repo}/actions/jobs/{job_id}/logs", "operation_id": "repoGetActionJobLogs"}, + "forgejo_get_action_run_logs": {"method": "get", "path": "/repos/{owner}/{repo}/actions/runs/{run_id}/logs", "operation_id": "repoGetActionRunLogs"}, + "forgejo_list_action_run_artifacts": {"method": "get", "path": "/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", "operation_id": "ListActionRunArtifacts"}, + "forgejo_cancel_action_run": {"method": "post", "path": "/repos/{owner}/{repo}/actions/runs/{run_id}/cancel", "operation_id": "CancelActionRun"}, + "forgejo_delete_action_run": {"method": "delete", "path": "/repos/{owner}/{repo}/actions/runs/{run_id}", "operation_id": "DeleteActionRun"}, "forgejo_dispatch_workflow": {"method": "post", "path": "/repos/{owner}/{repo}/actions/workflows/{workflowfilename}/dispatches", "operation_id": "DispatchWorkflow"}, "forgejo_create_tag": {"method": "post", "path": "/repos/{owner}/{repo}/tags", "operation_id": "repoCreateTag"}, "forgejo_create_release": {"method": "post", "path": "/repos/{owner}/{repo}/releases", "operation_id": "repoCreateRelease"} diff --git a/tests/unit/test_audit_redaction.py b/tests/unit/test_audit_redaction.py index a30e19b..bdd1abd 100644 --- a/tests/unit/test_audit_redaction.py +++ b/tests/unit/test_audit_redaction.py @@ -48,6 +48,26 @@ def test_redaction_truncates_large_text_and_extracts_safe_target() -> None: } +def test_extract_target_includes_action_identifiers() -> None: + assert extract_target( + { + "owner": "patrick", + "repo": "forgejo-mcp", + "run_id": 42, + "job_id": 51, + "attempt": 2, + "workflow_id": "ci.yml", + } + ) == { + "owner": "patrick", + "repo": "forgejo-mcp", + "run_id": 42, + "job_id": 51, + "attempt": 2, + "workflow_id": "ci.yml", + } + + def test_result_summary_counts_git_tree_entries() -> None: summary, truncated = summarize_result({"entries": [{"path": "README.md"}], "truncated": False}) diff --git a/tests/unit/test_forgejo_action_tools.py b/tests/unit/test_forgejo_action_tools.py new file mode 100644 index 0000000..040433c --- /dev/null +++ b/tests/unit/test_forgejo_action_tools.py @@ -0,0 +1,188 @@ +import io +import zipfile + +import httpx + +from forgejo_mcp.forgejo.client import MAX_ACTION_LOG_BYTES, ForgejoClient + + +def action_run_payload() -> dict[str, object]: + return { + "id": 42, + "index_in_repo": 7, + "title": "CI", + "event": "push", + "status": "success", + "workflow_id": "ci.yml", + "commit_sha": "abc123", + "prettyref": "main", + "html_url": "https://git.example.test/patrick/repo/actions/runs/7", + "created": "2026-08-18T10:00:00Z", + "started": "2026-08-18T10:00:01Z", + "stopped": "2026-08-18T10:01:00Z", + "updated": "2026-08-18T10:01:00Z", + "duration": 59, + } + + +async def test_list_and_get_action_runs_normalize_v16_payloads() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/actions/runs"): + assert request.url.params.multi_items() == [ + ("page", "2"), + ("limit", "1"), + ("event", "push"), + ("event", "workflow_dispatch"), + ("status", "success"), + ("workflow_id", "ci.yml"), + ("head_sha", "abc123"), + ("ref", "main"), + ("run_number", "7"), + ] + return httpx.Response( + 200, json={"total_count": 3, "workflow_runs": [action_run_payload()]} + ) + assert request.url.path.endswith("/actions/runs/42") + return httpx.Response(200, json=action_run_payload()) + + client = ForgejoClient(connect_timeout_seconds=2, transport=httpx.MockTransport(handler)) + common = { + "base_url": "https://git.example.test", + "token": "pat", + "verify_tls": True, + "owner": "patrick", + "repo": "repo", + } + runs = await client.list_action_runs( + **common, + event=["push", "workflow_dispatch"], + status=["success"], + workflow_id="ci.yml", + run_number=7, + head_sha="abc123", + ref="main", + page=2, + limit=1, + ) + run = await client.get_action_run(**common, run_id=42) + + assert runs["total_count"] == 3 + assert runs["has_more"] is True + assert runs["items"][0]["run_number"] == 7 + assert run["head_sha"] == "abc123" + assert run["completed_at"] == "2026-08-18T10:01:00Z" + + +async def test_list_action_jobs_and_artifacts() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/jobs"): + return httpx.Response( + 200, + json=[ + { + "id": 51, + "run_id": 42, + "name": "test", + "status": "success", + "attempt": 1, + "runs_on": ["docker"], + "needs": [], + } + ], + ) + assert request.url.path.endswith("/artifacts") + assert dict(request.url.params) == {"page": "1", "limit": "30", "name": "coverage"} + return httpx.Response( + 200, + json=[ + { + "id": 61, + "run_id": 42, + "name": "coverage", + "size_in_bytes": 1234, + "expired": False, + "created_at": "2026-08-18T10:01:00Z", + "expires_at": None, + "updated_at": "2026-08-18T10:01:00Z", + "archive_download_url": "https://git.example.test/artifacts/61.zip", + } + ], + ) + + client = ForgejoClient(connect_timeout_seconds=2, transport=httpx.MockTransport(handler)) + common = { + "base_url": "https://git.example.test", + "token": "pat", + "verify_tls": True, + "owner": "patrick", + "repo": "repo", + "run_id": 42, + } + jobs = await client.list_action_run_jobs(**common) + artifacts = await client.list_action_run_artifacts(**common, name="coverage", page=1, limit=30) + + assert jobs.items[0]["runner_labels"] == ["docker"] + assert jobs.truncated is False + assert artifacts.items[0]["size_in_bytes"] == 1234 + + +async def test_action_logs_are_bounded_and_run_archive_is_extracted() -> None: + archive_buffer = io.BytesIO() + with zipfile.ZipFile(archive_buffer, "w") as archive: + archive.writestr("test-51-attempt-1.log", "tests passed\n") + archive.writestr("lint-52-attempt-1.log", "lint passed\n") + archive_bytes = archive_buffer.getvalue() + oversized_log = b"x" * (MAX_ACTION_LOG_BYTES + 1) + + def handler(request: httpx.Request) -> httpx.Response: + if "/actions/jobs/51/logs" in request.url.path: + assert request.url.params["attempt"] == "2" + assert request.headers["Accept"] == "text/plain" + return httpx.Response(200, content=oversized_log) + assert request.url.path.endswith("/actions/runs/42/logs") + assert request.headers["Accept"] == "application/zip" + return httpx.Response(200, content=archive_bytes) + + client = ForgejoClient(connect_timeout_seconds=2, transport=httpx.MockTransport(handler)) + common = { + "base_url": "https://git.example.test", + "token": "pat", + "verify_tls": True, + "owner": "patrick", + "repo": "repo", + } + job_log = await client.get_action_job_log(**common, job_id=51, attempt=2) + run_logs = await client.get_action_run_logs(**common, run_id=42) + + assert job_log["truncated"] is True + assert len(job_log["content"]) == MAX_ACTION_LOG_BYTES + assert [item["name"] for item in run_logs["files"]] == [ + "test-51-attempt-1.log", + "lint-52-attempt-1.log", + ] + assert run_logs["files_truncated"] is False + + +async def test_cancel_and_delete_action_run_use_native_routes() -> None: + seen: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path)) + return httpx.Response(204) + + client = ForgejoClient(connect_timeout_seconds=2, transport=httpx.MockTransport(handler)) + common = { + "base_url": "https://git.example.test", + "token": "pat", + "verify_tls": True, + "owner": "patrick", + "repo": "repo", + "run_id": 42, + } + await client.cancel_action_run(**common) + await client.delete_action_run(**common) + + assert seen == [ + ("POST", "/api/v1/repos/patrick/repo/actions/runs/42/cancel"), + ("DELETE", "/api/v1/repos/patrick/repo/actions/runs/42"), + ] diff --git a/tests/unit/test_tool_authorization.py b/tests/unit/test_tool_authorization.py index eea7da2..42cb9aa 100644 --- a/tests/unit/test_tool_authorization.py +++ b/tests/unit/test_tool_authorization.py @@ -58,6 +58,14 @@ def test_registry_contains_stable_default_disabled_tool_spec() -> None: "forgejo_merge_pull_request", "forgejo_get_pull_request_merge_status", "forgejo_get_commit_status", + "forgejo_list_action_runs", + "forgejo_get_action_run", + "forgejo_list_action_run_jobs", + "forgejo_get_action_job_log", + "forgejo_get_action_run_logs", + "forgejo_list_action_run_artifacts", + "forgejo_cancel_action_run", + "forgejo_delete_action_run", "forgejo_dispatch_workflow", "forgejo_create_tag", "forgejo_create_release",