From d9ce4e240fe3eadfff4a2b14682216a6a2527476 Mon Sep 17 00:00:00 2001 From: emil Date: Sun, 28 Jun 2026 13:13:06 +0000 Subject: [PATCH] GRM-113: feat: upgrade all dependencies, add trigger-workflow command --- .env.example | 3 + .gitea/workflows/ci.yml | 1 + .gitea/workflows/post-merge.yml | 1 + README.md | 2 + ansible/requirements.yml | 6 +- pyproject.toml | 14 +-- src/gitea_runner_manager/cli.py | 80 ++++++++++++++ src/gitea_runner_manager/gitea_client.py | 86 +++++++++++++++ src/gitea_runner_manager/translations.json | 96 +++++++++++++++++ tests/unit/test_cli.py | 88 +++++++++++++++ tests/unit/test_gitea_client.py | 120 +++++++++++++++++++++ 11 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 src/gitea_runner_manager/gitea_client.py create mode 100644 tests/unit/test_gitea_client.py diff --git a/.env.example b/.env.example index fcc7177..9a79363 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ GITEA_REGISTRATION_TOKEN=your-registration-token # Default SSH user for remote hosts (optional, overrides --user) # GITEA_RUNNER_USER=ubuntu +# Repository for grm trigger-workflow (optional, default: oblachno-oss/grm) +# GRM_REPO=oblachno-oss/grm + # Default SSH private key path (optional, overrides --key) # GITEA_RUNNER_KEY=~/.ssh/id_ed25519 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ea0870a..0ca1bf9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -188,6 +188,7 @@ jobs: CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} RUN_ID: ${{ github.run_id }} + ANSIBLE_INJECT_INVOCATION: "1" JOB_NAME: ${{ github.job }} MATRIX_INDEX: ${{ matrix.runner-index }} GITEA_REPOSITORY: ${{ github.repository }} diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index a044c5f..b8b89ea 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -29,6 +29,7 @@ name: Post-merge on: push: branches: [master] + workflow_dispatch: env: CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} diff --git a/README.md b/README.md index 8dd7a1c..aeeffcd 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc | `grm remove --force` | Remove only the local registry entry (skip remote cleanup) | | `grm list` | List all registered runners with live status | | `grm list --no-status` | List registered runners without SSH status checks | +| `grm trigger-workflow ` | Trigger a Gitea Actions workflow via the API | +| `grm trigger-workflow --list` | List available workflows in the repository | | `grm --version` | Show the installed version | All lifecycle commands (`start`, `stop`, `restart`, `enable`, `disable`, `status`, `remove`) work by runner name and pull connection details from the local registry. You can override any stored value with `--host`, `--user`, or `--key`. diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 0bde23b..70546c4 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -1,5 +1,7 @@ collections: - name: community.general - version: ">=13.0.1" + version: ">=13.1.0" - name: ansible.posix - version: ">=1.5.4" + version: ">=2.2.0" + - name: community.docker + version: ">=5.2.1" diff --git a/pyproject.toml b/pyproject.toml index d0e8143..db9499f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,8 @@ classifiers = [ ] dependencies = [ "python-dotenv>=1.2.2", - "click>=8.4.1", - "ansible>=14.0.0", + "click>=8.4.2", + "ansible>=14.1.0", ] [project.scripts] @@ -29,7 +29,7 @@ version = {attr = "gitea_runner_manager.__version__"} # Minimal deps for CI scripts that only need click/dotenv # (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.) ci = [ - "pytest>=9.1.0", + "pytest>=9.1.1", "pytest-cov>=7.1.0", "build>=1.5.0", "twine>=6.2.0", @@ -38,10 +38,10 @@ ci = [ ] # Lint and type-checking tools (quality job) lint = [ - "ruff>=0.15.17", - "pyright>=1.1.410", - "bandit>=1.8.2", - "pip-audit>=2.10", + "ruff>=0.15.20", + "pyright>=1.1.411", + "bandit>=1.9.4", + "pip-audit>=2.10.1", "pre-commit>=4.6.0", "ansible-lint>=26.4.0", ] diff --git a/src/gitea_runner_manager/cli.py b/src/gitea_runner_manager/cli.py index dd64e2a..46fb293 100644 --- a/src/gitea_runner_manager/cli.py +++ b/src/gitea_runner_manager/cli.py @@ -14,6 +14,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk from . import __version__ from .exceptions import GRMError +from .gitea_client import GiteaAPIError, GiteaWorkflowClient from .i18n import _ from .runner_manager import RunnerManager @@ -468,3 +469,82 @@ def list_runners(ask_become_pass: bool, no_status: bool) -> None: click.echo("-" * 90) for r in runners: click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['labels']:<30} {r['status']}") + + +@cli.command(name="trigger-workflow", help=_("Trigger a Gitea Actions workflow via the API.")) +@click.argument("workflow_id", required=False) +@click.option( + "--repo", + default=lambda: os.getenv("GRM_REPO", "oblachno-oss/grm"), + help=_("Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)"), +) +@click.option( + "--ref", + default="master", + help=_("Git ref to run the workflow on (default: master)"), +) +@click.option( + "--url", + default=lambda: os.getenv("GITEA_URL", ""), + help=_("Gitea URL (env: GITEA_URL)"), +) +@click.option( + "--token", + default=lambda: os.getenv("CI_GITEA_TOKEN"), + help=_("Gitea API token (env: CI_GITEA_TOKEN)"), +) +@click.option( + "--list", + "list_only", + is_flag=True, + default=False, + help=_("List available workflows instead of triggering one"), +) +def trigger_workflow( + workflow_id: str, + repo: str, + ref: str, + url: str, + token: str | None, + list_only: bool, +) -> None: + """Trigger a Gitea Actions workflow dispatch event.""" + if not url: + raise click.ClickException(_("GITEA_URL is required (set --url or GITEA_URL env var)")) + if not token: + raise click.ClickException(_("CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)")) + if not list_only and not workflow_id: + raise click.ClickException(_("WORKFLOW_ID is required unless --list is used")) + + client = GiteaWorkflowClient(url, token) + owner, repo_name = repo.split("/", 1) + + if list_only: + workflows = client.list_workflows(owner, repo_name) + if not workflows: + click.echo(_("No workflows found in {repo}", repo=repo)) + return + click.echo(f"{'ID':<30} {'NAME':<20} {'PATH':<25} {'STATE'}") + click.echo("-" * 85) + for wf in workflows: + wf_id = str(wf.get("id", "")) + wf_name = wf.get("name", "") + wf_path = wf.get("path", "") + wf_state = wf.get("state", "") + click.echo(f"{wf_id:<30} {wf_name:<20} {wf_path:<25} {wf_state}") + return + + click.echo(_("Triggering workflow {wf} on {repo}@{ref}...", wf=workflow_id, repo=repo, ref=ref)) + try: + result = client.dispatch_workflow(owner, repo_name, workflow_id, ref) + except GiteaAPIError as e: + raise click.ClickException(str(e)) from e + + if result and result.get("id"): + run_id: Any = result["id"] + click.echo(_("Workflow triggered successfully. Run ID: {run_id}", run_id=run_id)) + if result.get("html_url"): + html_url: Any = result["html_url"] + click.echo(f" {html_url}") + else: + click.echo(_("Workflow triggered successfully.")) diff --git a/src/gitea_runner_manager/gitea_client.py b/src/gitea_runner_manager/gitea_client.py new file mode 100644 index 0000000..296b767 --- /dev/null +++ b/src/gitea_runner_manager/gitea_client.py @@ -0,0 +1,86 @@ +"""Minimal Gitea API client for workflow operations. + +Uses urllib from the standard library to avoid adding requests as a +runtime dependency. Only covers the Actions workflow dispatch endpoint. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request # noqa: PTH123 # nosec B404 +from contextlib import suppress +from typing import Any + + +class GiteaAPIError(Exception): + """Raised when a Gitea API call fails.""" + + def __init__(self, status: int, message: str) -> None: + super().__init__(f"Gitea API error {status}: {message}") + self.status = status + self.message = message + + +class GiteaWorkflowClient: + """Thin client for Gitea Actions workflow API endpoints.""" + + def __init__(self, base_url: str, token: str) -> None: + self._base_url = base_url.rstrip("/") + self._token = token + + def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any] | None: + url = f"{self._base_url}/api/v1{path}" + data = json.dumps(body).encode("utf-8") if body else None + req = urllib.request.Request( # nosec B310 + url, + data=data, + method=method, + ) + req.add_header("Authorization", f"token {self._token}") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(req) as resp: # noqa: PTH123 # nosec B310 + if resp.status == 204: + return None + raw = resp.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + with suppress(json.JSONDecodeError, ValueError): + detail = json.loads(detail).get("message", detail) + raise GiteaAPIError(e.code, detail) from e + + def list_workflows(self, owner: str, repo: str) -> list[dict[str, Any]]: + """List all workflows in a repository.""" + result = self._request("GET", f"/repos/{owner}/{repo}/actions/workflows") + if result is None: + return [] + return result.get("workflows", []) + + def dispatch_workflow( + self, + owner: str, + repo: str, + workflow_id: str, + ref: str = "master", + inputs: dict[str, str] | None = None, + ) -> dict[str, Any] | None: + """Trigger a workflow dispatch event. + + Args: + owner: Repository owner. + repo: Repository name. + workflow_id: Workflow file name (e.g. "ci.yml") or numeric ID. + ref: Git ref (branch/tag) to run on. Defaults to "master". + inputs: Optional workflow inputs. + + Returns: + Run details dict if return_run_details is requested, else None. + """ + path = f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches?return_run_details=true" + body: dict[str, Any] = {"ref": ref} + if inputs: + body["inputs"] = inputs + return self._request("POST", path, body) diff --git a/src/gitea_runner_manager/translations.json b/src/gitea_runner_manager/translations.json index b1f4677..8b63c21 100644 --- a/src/gitea_runner_manager/translations.json +++ b/src/gitea_runner_manager/translations.json @@ -630,5 +630,101 @@ "pl": "nieznany", "ru": "неизвестно", "zh": "未知" + }, + "CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)": { + "bg": "CI_GITEA_TOKEN е задължителен (задайте --token или CI_GITEA_TOKEN env var)", + "de": "CI_GITEA_TOKEN ist erforderlich (setzen Sie --token oder CI_GITEA_TOKEN env var)", + "en": "CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)", + "pl": "CI_GITEA_TOKEN jest wymagany (ustaw --token lub CI_GITEA_TOKEN env var)", + "ru": "CI_GITEA_TOKEN обязателен (установите --token или CI_GITEA_TOKEN env var)", + "zh": "需要 CI_GITEA_TOKEN(设置 --token 或 CI_GITEA_TOKEN 环境变量)" + }, + "GITEA_URL is required (set --url or GITEA_URL env var)": { + "bg": "GITEA_URL е задължителен (задайте --url или GITEA_URL env var)", + "de": "GITEA_URL ist erforderlich (setzen Sie --url oder GITEA_URL env var)", + "en": "GITEA_URL is required (set --url or GITEA_URL env var)", + "pl": "GITEA_URL jest wymagany (ustaw --url lub GITEA_URL env var)", + "ru": "GITEA_URL обязателен (установите --url или GITEA_URL env var)", + "zh": "需要 GITEA_URL(设置 --url 或 GITEA_URL 环境变量)" + }, + "Git ref to run the workflow on (default: master)": { + "bg": "Git ref за изпълнение на работния процес (по подразбиране: master)", + "de": "Git-Ref für die Workflow-Ausführung (Standard: master)", + "en": "Git ref to run the workflow on (default: master)", + "pl": "Git ref do uruchomienia workflow (domyślnie: master)", + "ru": "Git ref для запуска workflow (по умолчанию: master)", + "zh": "运行工作流的 Git ref(默认:master)" + }, + "Gitea API token (env: CI_GITEA_TOKEN)": { + "bg": "Gitea API токен (env: CI_GITEA_TOKEN)", + "de": "Gitea API-Token (env: CI_GITEA_TOKEN)", + "en": "Gitea API token (env: CI_GITEA_TOKEN)", + "pl": "Token API Gitea (env: CI_GITEA_TOKEN)", + "ru": "Токен API Gitea (env: CI_GITEA_TOKEN)", + "zh": "Gitea API 令牌(环境变量:CI_GITEA_TOKEN)" + }, + "List available workflows instead of triggering one": { + "bg": "Списък на наличните работни процеси вместо изпълнение", + "de": "Verfügbare Workflows auflisten statt auszuführen", + "en": "List available workflows instead of triggering one", + "pl": "Wyświetl dostępne workflow zamiast uruchamiać", + "ru": "Список доступных workflow вместо запуска", + "zh": "列出可用工作流而不是触发" + }, + "No workflows found in {repo}": { + "bg": "Няма намерени работни процеси в {repo}", + "de": "Keine Workflows in {repo} gefunden", + "en": "No workflows found in {repo}", + "pl": "Nie znaleziono workflow w {repo}", + "ru": "В {repo} не найдено workflow", + "zh": "在 {repo} 中未找到工作流" + }, + "Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)": { + "bg": "Хранилище във формат owner/repo (env: GRM_REPO, по подразбиране: oblachno-oss/grm)", + "de": "Repository im owner/repo-Format (env: GRM_REPO, Standard: oblachno-oss/grm)", + "en": "Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)", + "pl": "Repozytorium w formacie owner/repo (env: GRM_REPO, domyślnie: oblachno-oss/grm)", + "ru": "Репозиторий в формате owner/repo (env: GRM_REPO, по умолчанию: oblachno-oss/grm)", + "zh": "仓库格式为 owner/repo(环境变量:GRM_REPO,默认:oblachno-oss/grm)" + }, + "Trigger a Gitea Actions workflow via the API.": { + "bg": "Стартиране на Gitea Actions работен процес чрез API.", + "de": "Einen Gitea Actions-Workflow über die API auslösen.", + "en": "Trigger a Gitea Actions workflow via the API.", + "pl": "Uruchom workflow Gitea Actions przez API.", + "ru": "Запустить workflow Gitea Actions через API.", + "zh": "通过 API 触发 Gitea Actions 工作流。" + }, + "Triggering workflow {wf} on {repo}@{ref}...": { + "bg": "Стартиране на работен процес {wf} в {repo}@{ref}...", + "de": "Workflow {wf} auf {repo}@{ref} wird ausgelöst...", + "en": "Triggering workflow {wf} on {repo}@{ref}...", + "pl": "Uruchamianie workflow {wf} na {repo}@{ref}...", + "ru": "Запуск workflow {wf} на {repo}@{ref}...", + "zh": "正在触发工作流 {wf} 于 {repo}@{ref}..." + }, + "WORKFLOW_ID is required unless --list is used": { + "bg": "WORKFLOW_ID е задължителен, освен ако не се използва --list", + "de": "WORKFLOW_ID ist erforderlich, es sei denn --list wird verwendet", + "en": "WORKFLOW_ID is required unless --list is used", + "pl": "WORKFLOW_ID jest wymagany, chyba że użyto --list", + "ru": "WORKFLOW_ID обязателен, если не используется --list", + "zh": "除非使用 --list,否则需要 WORKFLOW_ID" + }, + "Workflow triggered successfully.": { + "bg": "Работният процес е стартиран успешно.", + "de": "Workflow erfolgreich ausgelöst.", + "en": "Workflow triggered successfully.", + "pl": "Workflow uruchomiony pomyślnie.", + "ru": "Workflow успешно запущен.", + "zh": "工作流触发成功。" + }, + "Workflow triggered successfully. Run ID: {run_id}": { + "bg": "Работният процес е стартиран успешно. ID на изпълнение: {run_id}", + "de": "Workflow erfolgreich ausgelöst. Run-ID: {run_id}", + "en": "Workflow triggered successfully. Run ID: {run_id}", + "pl": "Workflow uruchomiony pomyślnie. ID uruchomienia: {run_id}", + "ru": "Workflow успешно запущен. ID запуска: {run_id}", + "zh": "工作流触发成功。运行 ID:{run_id}" } } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9cd9b66..9cc4a97 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -918,3 +918,91 @@ class TestCLI: with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/ansible.txt"}, clear=True): assert _get_become_password_file() == "/tmp/ansible.txt" + + +class TestTriggerWorkflow: + """Tests for the trigger-workflow CLI command.""" + + def test_trigger_workflow_success(self) -> None: + runner = CliRunner(env=_TEST_ENV) + with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.dispatch_workflow.return_value = { + "id": 42, + "html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42", + } + result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"]) + assert result.exit_code == 0 + assert "42" in result.output + mock_client.dispatch_workflow.assert_called_once_with("oblachno-oss", "grm", "ci.yml", "master") + + def test_trigger_workflow_no_url(self) -> None: + runner = CliRunner() + with patch.dict("os.environ", {}, clear=True): + result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"]) + assert result.exit_code != 0 + assert "GITEA_URL" in result.output + + def test_trigger_workflow_no_token(self) -> None: + runner = CliRunner() + with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com"}, clear=True): + result = runner.invoke(cli, ["trigger-workflow", "ci.yml"]) + assert result.exit_code != 0 + assert "CI_GITEA_TOKEN" in result.output + + def test_trigger_workflow_list(self) -> None: + runner = CliRunner(env=_TEST_ENV) + with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.list_workflows.return_value = [ + {"id": 1, "name": "CI", "path": "ci.yml", "state": "active"}, + {"id": 2, "name": "Post-merge", "path": "post-merge.yml", "state": "active"}, + ] + result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"]) + assert result.exit_code == 0 + assert "CI" in result.output + assert "Post-merge" in result.output + mock_client.list_workflows.assert_called_once_with("oblachno-oss", "grm") + + def test_trigger_workflow_list_empty(self) -> None: + runner = CliRunner(env=_TEST_ENV) + with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.list_workflows.return_value = [] + result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"]) + assert result.exit_code == 0 + assert "No workflows" in result.output + + def test_trigger_workflow_no_workflow_id(self) -> None: + runner = CliRunner(env=_TEST_ENV) + result = runner.invoke(cli, ["trigger-workflow", "--token", "tok"]) + assert result.exit_code != 0 + assert "WORKFLOW_ID" in result.output + + def test_trigger_workflow_api_error(self) -> None: + from gitea_runner_manager.gitea_client import GiteaAPIError + + runner = CliRunner(env=_TEST_ENV) + with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.dispatch_workflow.side_effect = GiteaAPIError(404, "workflow not found") + result = runner.invoke(cli, ["trigger-workflow", "nonexistent.yml", "--token", "tok"]) + assert result.exit_code != 0 + assert "404" in result.output + + def test_trigger_workflow_custom_repo_and_ref(self) -> None: + runner = CliRunner(env=_TEST_ENV) + with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.dispatch_workflow.return_value = None + result = runner.invoke( + cli, + ["trigger-workflow", "build.yml", "--repo", "myorg/myrepo", "--ref", "develop", "--token", "tok"], + ) + assert result.exit_code == 0 + mock_client.dispatch_workflow.assert_called_once_with("myorg", "myrepo", "build.yml", "develop") diff --git a/tests/unit/test_gitea_client.py b/tests/unit/test_gitea_client.py new file mode 100644 index 0000000..6f5b350 --- /dev/null +++ b/tests/unit/test_gitea_client.py @@ -0,0 +1,120 @@ +"""Unit tests for gitea_client module.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from gitea_runner_manager.gitea_client import GiteaAPIError, GiteaWorkflowClient + + +class TestGiteaWorkflowClient: + def _client(self) -> GiteaWorkflowClient: + return GiteaWorkflowClient("https://git.example.com", "test-token") + + def test_list_workflows(self) -> None: + client = self._client() + mock_response = {"workflows": [{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"}]} + with patch.object(client, "_request", return_value=mock_response) as mock_req: + result = client.list_workflows("oblachno-oss", "grm") + assert len(result) == 1 + assert result[0]["name"] == "CI" + mock_req.assert_called_once_with("GET", "/repos/oblachno-oss/grm/actions/workflows") + + def test_list_workflows_empty(self) -> None: + client = self._client() + with patch.object(client, "_request", return_value=None): + result = client.list_workflows("oblachno-oss", "grm") + assert result == [] + + def test_dispatch_workflow(self) -> None: + client = self._client() + mock_response = {"id": 42, "html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42"} + with patch.object(client, "_request", return_value=mock_response) as mock_req: + result = client.dispatch_workflow("oblachno-oss", "grm", "ci.yml", ref="master") + assert result is not None + assert result["id"] == 42 + mock_req.assert_called_once_with( + "POST", + "/repos/oblachno-oss/grm/actions/workflows/ci.yml/dispatches?return_run_details=true", + {"ref": "master"}, + ) + + def test_dispatch_workflow_with_inputs(self) -> None: + client = self._client() + with patch.object(client, "_request", return_value=None) as mock_req: + client.dispatch_workflow("oblachno-oss", "grm", "build.yml", ref="master", inputs={"env": "prod"}) + mock_req.assert_called_once_with( + "POST", + "/repos/oblachno-oss/grm/actions/workflows/build.yml/dispatches?return_run_details=true", + {"ref": "master", "inputs": {"env": "prod"}}, + ) + + def test_dispatch_workflow_api_error(self) -> None: + client = self._client() + with patch.object(client, "_request", side_effect=GiteaAPIError(404, "workflow not found")): + with pytest.raises(GiteaAPIError) as exc_info: + client.dispatch_workflow("oblachno-oss", "grm", "nonexistent.yml") + assert exc_info.value.status == 404 + + +class TestGiteaWorkflowClientRequest: + """Test the underlying _request method with mocked urllib.""" + + def test_request_success(self) -> None: + client = GiteaWorkflowClient("https://git.example.com/", "tok") + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.read.return_value = json.dumps({"ok": True}).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: + result = client._request("GET", "/test") + assert result == {"ok": True} + mock_urlopen.assert_called_once() + + def test_request_204_no_content(self) -> None: + client = GiteaWorkflowClient("https://git.example.com", "tok") + mock_resp = MagicMock() + mock_resp.status = 204 + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = client._request("POST", "/test", {"ref": "master"}) + assert result is None + + def test_request_http_error(self) -> None: + import urllib.error + + client = GiteaWorkflowClient("https://git.example.com", "tok") + err = urllib.error.HTTPError( + "https://git.example.com/api/v1/test", + 404, + "Not Found", + {}, + __import__("io").BytesIO(b'{"message": "resource not found"}'), + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(GiteaAPIError) as exc_info: + client._request("GET", "/test") + assert exc_info.value.status == 404 + assert "resource not found" in exc_info.value.message + + def test_request_http_error_non_json(self) -> None: + import urllib.error + + client = GiteaWorkflowClient("https://git.example.com", "tok") + err = urllib.error.HTTPError( + "https://git.example.com/api/v1/test", + 500, + "Internal Server Error", + {}, + __import__("io").BytesIO(b"plain text error"), + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(GiteaAPIError) as exc_info: + client._request("GET", "/test") + assert exc_info.value.status == 500 + assert "plain text error" in exc_info.value.message