diff --git a/.env.example b/.env.example index 102bbcc..fcc7177 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,10 @@ GITEA_REGISTRATION_TOKEN=your-registration-token # Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL CI_GITEA_USERNAME=emil +# Vikunja API token (required for `make create-task` dev workflow) +# Generate at: Vikunja → Settings → API Tokens +# VIKUNJA_TOKEN=your-vikunja-api-token + # devx configuration (GRM-specific overrides) # Task prefix for Vikunja task IDs DEVX_TASK_PREFIX=GRM diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1973df7..c6dc9c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,10 +64,3 @@ repos: types: [python] pass_filenames: false stages: [pre-push] - - - id: commit-msg - name: validate commit message - entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg - language: system - stages: [commit-msg] - pass_filenames: true diff --git a/AGENTS.md b/AGENTS.md index e299262..f73c08b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,8 +98,7 @@ docs: update README ### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label) -**Review checklist:** Every PR is reviewed against -[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 13 categories covering +**Review checklist:** Every PR is reviewed against 13 categories covering architecture, code quality, security, i18n, testing, performance, UX, documentation, workflow compliance, maintainability, resource management, backwards compatibility, and logging. @@ -120,11 +119,11 @@ the **[auto]** items in the checklist: - Commit conventions (conventional commit format on PR commits) The automated review posts inline comments on specific lines and -includes a link to the full checklist. The agent **must** address all +includes a summary of the checklist categories. The agent **must** address all `REQUEST_CHANGES` issues before proceeding. **Manual review (agent):** After the automated review passes, the agent -must go through **every category** in `REVIEW_CHECKLIST.md` and verify +must go through **every category** listed above and verify the **[manual]** items by reviewing the full diff (`git diff master...HEAD`). @@ -145,7 +144,7 @@ an approval review with `--checklist-confirmed` and `--checklist-categories`: CI_GITEA_TOKEN= python -m devx.ci.pr_review \ --event APPROVE --checklist-confirmed \ --checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \ - --body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: . Security: . Tests: . Docs: ." + --body "All 13 checklist categories verified. Architecture: . Security: . Tests: . Docs: ." ``` The `--checklist-confirmed` flag is **required** for APPROVE events — @@ -252,16 +251,16 @@ via `[tool.devx.classify]` in `pyproject.toml`. - `scripts/**` — Dev tools and CI/CD automation (not part of installed package) - `docs/**` — Documentation - `tests/**` — Test files -- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `REVIEW_CHECKLIST.md` — Project docs +- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md` — Project docs - `Makefile`, `cliff.toml`, `uv.lock` — Build tooling -- `.pre-commit-config.yaml`, `.ruff.toml`, `.ansible-lint`, `.checkmake.ini`, `.editorconfig` — Lint config -- `.env.example`, `.gitignore`, `.gitattributes` — Config +- `.pre-commit-config.yaml`, `.ansible-lint`, `.checkmake.ini` — Lint config (ruff config is in `pyproject.toml`) +- `.env.example`, `.gitignore` — Config - `.devin/**` — Agent/CI tooling config - `hooks/**` — Git hooks - `activate.sh`, `activate.fish`, `activate.zsh` — Generated venv scripts **User-facing paths** (tool changes → release needed) — everything else: -- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py` and `api_clients.py`) +- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py`) - `ansible/**` — Ansible role - `pyproject.toml` — Package metadata - Any new file type not in the allowlist diff --git a/Makefile b/Makefile index 1720d5e..85f7c91 100644 --- a/Makefile +++ b/Makefile @@ -109,28 +109,35 @@ update: $(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) start: - @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make start NAME=runner1"; exit 1; fi $(BIN)/grm start $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) stop: - @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make stop NAME=runner1"; exit 1; fi $(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) +restart: + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make restart NAME=runner1"; exit 1; fi + $(BIN)/grm restart $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) + enable: - @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make enable NAME=runner1"; exit 1; fi $(BIN)/grm enable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) disable: - @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make disable NAME=runner1"; exit 1; fi $(BIN)/grm disable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) status: - @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make status NAME=runner1"; exit 1; fi $(BIN)/grm status $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) remove: - @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi - $(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) + @if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make remove NAME=runner1"; exit 1; fi + $(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(FORCE),--force,) $(if $(ASK_BECOME_PASS),--ask-become-pass,) + +list: + $(BIN)/grm list $(if $(NO_STATUS),--no-status,) $(if $(ASK_BECOME_PASS),--ask-become-pass,) # --- Aliases to devx.mak targets ---------------------------------------------- lint-ruff: devx-lint-ruff diff --git a/README.md b/README.md index f1a102d..8e3609e 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc | `grm disable ` | Disable and deregister a runner | | `grm status ` | Check the status of a registered runner | | `grm remove ` | Remove a runner completely (with remote cleanup) | +| `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 --version` | Show the installed version | @@ -360,8 +361,7 @@ grm install | `executor.py` | Ansible subprocess execution with log capture | | `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` | | `i18n.py` | Internationalisation (en, bg, de, ru, zh, pl) | -| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) | -| `config.py` | Configuration constants (API URLs, repo owner/name) | +| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) | | `logging_config.py` | Logging to `~/.local/state/grm/logs/grm.log` | | `report.py` | Operation report tracking with step status | | `ui.py` | Colorised console output via Click | diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 5c05f6e..dfaf85f 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -210,12 +210,10 @@ The Python CLI layer (`src/gitea_runner_manager/`) consists of the following mod | `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files, streams output to log files | | `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` — stores connection metadata | | `i18n.py` | Internationalisation translations (en, bg, de, ru, zh, pl) — opt-in via `GRM_LANG` environment variable | -| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) | -| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables | +| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) | | `logging_config.py` | Logging configuration — writes all messages to `~/.local/state/grm/logs/grm.log` at DEBUG level | | `report.py` | Operation report tracking — prints a step-by-step report with status icons after each command | | `ui.py` | User-facing output utilities — colorised console output via `click.style`, with log file always receiving plain text | -| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts (not used by the CLI itself) | | `translations.json` | Translation strings for all supported languages | ## Logging diff --git a/docs/tech/contributing.md b/docs/tech/contributing.md index 2d85a12..8f1bc1c 100644 --- a/docs/tech/contributing.md +++ b/docs/tech/contributing.md @@ -155,7 +155,7 @@ Once all comments are addressed, post an approval review: CI_GITEA_TOKEN= python -m devx.ci.review_pr \ --event APPROVE --checklist-confirmed \ --checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \ - --body "All 13 REVIEW_CHECKLIST.md categories verified." + --body "All 13 checklist categories verified." ``` Then add the `ready-to-merge` label. The auto-merge workflow will: @@ -216,7 +216,7 @@ Not all changes require a new release. The project classifies changes using `dev - Lint config files, `.env.example`, `.gitignore` **User-facing paths** (release needed): -- `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`) +- `src/gitea_runner_manager/**` (except `__init__.py`) - `ansible/**` - `pyproject.toml` diff --git a/docs/tech/decision-log.md b/docs/tech/decision-log.md index 84f261d..06b3e7c 100644 --- a/docs/tech/decision-log.md +++ b/docs/tech/decision-log.md @@ -94,7 +94,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and ` **Decision:** Classify changed files into user-facing and workflow-only categories using `devx.ci.classify_changes`. Only user-facing changes trigger a release; workflow-only changes (CI, docs, tests, lint config) do not. -**Rationale:** Not all changes require a new release. CI workflow updates, documentation improvements, and test additions should not produce a new version tag. The classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`. The strategy is safe-by-default: any file NOT in the explicit workflow-only allowlist is treated as user-facing, preventing new file types from accidentally skipping releases. User-facing paths include `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`) and `ansible/**`. Workflow-only paths include `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`, and various config files. +**Rationale:** Not all changes require a new release. CI workflow updates, documentation improvements, and test additions should not produce a new version tag. The classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`. The strategy is safe-by-default: any file NOT in the explicit workflow-only allowlist is treated as user-facing, preventing new file types from accidentally skipping releases. User-facing paths include `src/gitea_runner_manager/**` (except `__init__.py`) and `ansible/**`. Workflow-only paths include `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`, and various config files. **Source:** `AGENTS.md` (Smart CI: User-Facing vs Workflow-Only Changes), `pyproject.toml` (`[tool.devx.classify]`) diff --git a/docs/tech/development-setup.md b/docs/tech/development-setup.md index 4c6c205..a741518 100644 --- a/docs/tech/development-setup.md +++ b/docs/tech/development-setup.md @@ -11,11 +11,9 @@ │ ├── registry.py # Local JSON runner registry │ ├── i18n.py # Translations (en, bg, de, ru, zh, pl) │ ├── exceptions.py # Custom exceptions -│ ├── config.py # Configuration constants │ ├── logging_config.py # Logging to ~/.local/state/grm/logs/ │ ├── report.py # Operation report tracking │ ├── ui.py # Colorised console output -│ ├── api_clients.py # Gitea/Vikunja API clients (for CI scripts) │ └── translations.json # Translation strings ├── ansible/ │ ├── roles/gitea-runner/ # Main Ansible role diff --git a/pyproject.toml b/pyproject.toml index ba6b586..d0e8143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", ] dependencies = [ - "requests>=2.34.2", "python-dotenv>=1.2.2", "click>=8.4.1", "ansible>=14.0.0", @@ -27,7 +26,7 @@ grm = "gitea_runner_manager.cli:cli" version = {attr = "gitea_runner_manager.__version__"} [project.optional-dependencies] -# Minimal deps for CI scripts that only need click/dotenv/requests +# 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", @@ -128,11 +127,8 @@ infrastructure = ["scripts/**"] # Infrastructure overrides — files that would default to user-facing # but are actually infrastructure: # - __init__.py: only contains __version__ (set by release.py, not user code) -# - api_clients.py: used only by tests and legacy CI scripts (now in devx), -# not by the grm CLI tool itself infrastructure_overrides = [ "src/gitea_runner_manager/__init__.py", - "src/gitea_runner_manager/api_clients.py", ] # User-facing overrides — safety override for broad infrastructure patterns diff --git a/src/gitea_runner_manager/api_clients.py b/src/gitea_runner_manager/api_clients.py deleted file mode 100644 index 57b1e9a..0000000 --- a/src/gitea_runner_manager/api_clients.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Reusable HTTP API clients for Gitea and Vikunja.""" - -from __future__ import annotations - -import logging -import time -from typing import Any - -import requests - -from .config import DEFAULT_TIMEOUT -from .exceptions import APIError - -logger = logging.getLogger("grm") - -# Retry configuration for transient errors (429, 5xx, connection errors) -MAX_RETRIES = 3 -RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8 -RETRY_STATUS_CODES = {429, 500, 502, 503, 504} - - -def _parse_error(e: requests.HTTPError) -> tuple[int, str]: - """Extract status code and message from an HTTPError response.""" - response = getattr(e, "response", None) - status = response.status_code if response is not None else 0 - try: - body: dict[str, Any] = response.json() if response is not None else {} - message: str = body.get("message", str(e)) - except Exception: - message = str(e) - return status, message - - -def _is_retryable(e: Exception) -> bool: - """Check if an exception is a transient error worth retrying.""" - if isinstance(e, requests.ConnectionError): - return True - if isinstance(e, requests.HTTPError): - status, _ = _parse_error(e) - return status in RETRY_STATUS_CODES - return isinstance(e, requests.Timeout) - - -class GiteaClient: - """Low-level Gitea REST API client with connection pooling.""" - - def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None: - self._base_url = base_url.rstrip("/") - self._owner = owner - self._repo = repo - self._session = requests.Session() - self._session.headers.update( - { - "Authorization": f"token {token}", - "Content-Type": "application/json", - } - ) - - def _url(self, path: str) -> str: - return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}" - - def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: - url = self._url(path) - last_exc: Exception | None = None - for attempt in range(MAX_RETRIES): - try: - response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs) - response.raise_for_status() - return response - except requests.HTTPError as e: - status, message = _parse_error(e) - if _is_retryable(e) and attempt < MAX_RETRIES - 1: - wait = RETRY_BACKOFF_BASE ** (attempt + 1) - logger.warning( - "Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)", - status, - method, - path, - wait, - attempt + 1, - MAX_RETRIES, - ) - time.sleep(wait) - last_exc = e - continue - raise APIError(status, message) from e - except (requests.ConnectionError, requests.Timeout) as e: - if attempt < MAX_RETRIES - 1: - wait = RETRY_BACKOFF_BASE ** (attempt + 1) - logger.warning( - "Connection error on %s %s, retrying in %ds (attempt %d/%d)", - method, - path, - wait, - attempt + 1, - MAX_RETRIES, - ) - time.sleep(wait) - last_exc = e - continue - raise APIError(0, str(e)) from e - # Should not reach here, but just in case - if last_exc: # pragma: no cover - raise APIError(0, str(last_exc)) from last_exc - raise APIError(0, "Max retries exceeded") # pragma: no cover - - # -- repo settings -- - - def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]: - """Update repository settings (e.g. auto-delete branch after merge).""" - r = self._request("PATCH", "", json=settings) - return r.json() - - # -- branch protection -- - - def list_branch_protections(self) -> list[dict[str, Any]]: - r = self._request("GET", "/branch_protections") - return r.json() - - def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]: - r = self._request("POST", "/branch_protections", json=config) - return r.json() - - def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]: - r = self._request("PATCH", f"/branch_protections/{branch}", json=config) - return r.json() - - def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]: - """Idempotent: create or update branch protection for the given branch.""" - existing = self.list_branch_protections() - for p in existing: - if p.get("branch_name") == branch: - update_config = {k: v for k, v in config.items() if k != "branch_name"} - return self.update_branch_protection(branch, update_config) - return self.create_branch_protection(config) - - # -- labels -- - - def list_labels(self) -> list[dict[str, Any]]: - r = self._request("GET", "/labels") - return r.json() - - def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]: - r = self._request( - "POST", - "/labels", - json={"name": name, "color": color, "description": description}, - ) - return r.json() - - def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None: - """Idempotent: create label if it doesn't already exist.""" - labels = self.list_labels() - for label in labels: - if label["name"] == name: - return None - return self.create_label(name, color, description) - - def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]: - """Create a new issue in the repository. - - Args: - labels: List of label IDs (integers, not names). - """ - payload: dict[str, Any] = {"title": title, "body": body} - if labels: - payload["labels"] = labels - r = self._request("POST", "/issues", json=payload) - return r.json() - - # -- pulls / releases -- - - def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]: - """Fetch labels currently attached to a pull request.""" - r = self._request("GET", f"/issues/{pr_number}/labels") - return r.json() - - def merge_pr(self, pr_number: str | int, merge_title: str) -> None: - payload = {"Do": "squash", "MergeTitleField": merge_title} - self._request("POST", f"/pulls/{pr_number}/merge", json=payload) - - def get_commit_status(self, sha: str) -> list[dict[str, Any]]: - """Fetch all status check contexts reported for a commit. - - Uses the combined status endpoint (/commits/{sha}/status) which - returns one entry per context (the latest), deduplicated server-side. - The plural endpoint (/commits/{sha}/statuses) returns every historical - entry including stale "pending" ones that never got updated. - """ - r = self._request("GET", f"/commits/{sha}/status") - data = r.json() - return data.get("statuses", []) - - def get_pr(self, pr_number: str | int) -> dict[str, Any]: - """Fetch pull request details including mergeable state.""" - r = self._request("GET", f"/pulls/{pr_number}") - return r.json() - - def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]: - """Fetch the list of files changed in a pull request.""" - r = self._request("GET", f"/pulls/{pr_number}/files") - return r.json() - - def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]: - """Fetch the commits included in a pull request.""" - r = self._request("GET", f"/pulls/{pr_number}/commits") - return r.json() - - def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]: - """Fetch reviews posted on a pull request.""" - r = self._request("GET", f"/pulls/{pr_number}/reviews") - return r.json() - - def create_review( - self, - pr_number: str | int, - event: str = "COMMENT", - body: str = "", - comments: list[dict[str, Any]] | None = None, - ) -> dict[str, Any]: - """Post a review on a pull request. - - Args: - event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``. - body: Top-level review body text. - comments: Line-level comments with ``path``, ``body``, - ``new_position`` (and optionally ``old_position``). - """ - # Map common event names to Gitea API values - event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"} - gitea_event = event_map.get(event, event) - payload: dict[str, Any] = {"event": gitea_event, "body": body} - if comments: - payload["comments"] = comments - r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload) - return r.json() - - def create_release( - self, - tag: str, - name: str = "", - body: str = "", - draft: bool = False, - prerelease: bool = False, - ) -> dict[str, Any]: - payload = { - "tag_name": tag, - "name": name or tag, - "body": body, - "draft": draft, - "prerelease": prerelease, - } - r = self._request("POST", "/releases", json=payload) - return r.json() - - def get_release_by_tag(self, tag: str) -> dict[str, Any] | None: - """Fetch a release by its tag name. Returns None if not found.""" - try: - r = self._request("GET", f"/releases/tags/{tag}") - return r.json() - except APIError: - return None - - def create_release_idempotent( - self, - tag: str, - name: str = "", - body: str = "", - draft: bool = False, - prerelease: bool = False, - ) -> dict[str, Any]: - """Create a release, or return the existing one if it already exists. - - This is idempotent — safe to call multiple times for the same tag. - """ - existing = self.get_release_by_tag(tag) - if existing: - logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id")) - return existing - return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease) - - -class VikunjaClient: - """Low-level Vikunja REST API client with connection pooling.""" - - def __init__(self, base_url: str, token: str) -> None: - self._base_url = base_url.rstrip("/") - self._session = requests.Session() - self._session.headers.update({"Authorization": f"Bearer {token}"}) - - def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: - url = f"{self._base_url}{path}" - last_exc: Exception | None = None - for attempt in range(MAX_RETRIES): - try: - response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs) - response.raise_for_status() - return response - except requests.HTTPError as e: - status, message = _parse_error(e) - if _is_retryable(e) and attempt < MAX_RETRIES - 1: - wait = RETRY_BACKOFF_BASE ** (attempt + 1) - logger.warning( - "Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)", - status, - method, - path, - wait, - attempt + 1, - MAX_RETRIES, - ) - time.sleep(wait) - last_exc = e - continue - raise APIError(status, message) from e - except (requests.ConnectionError, requests.Timeout) as e: - if attempt < MAX_RETRIES - 1: - wait = RETRY_BACKOFF_BASE ** (attempt + 1) - logger.warning( - "Connection error on %s %s, retrying in %ds (attempt %d/%d)", - method, - path, - wait, - attempt + 1, - MAX_RETRIES, - ) - time.sleep(wait) - last_exc = e - continue - raise APIError(0, str(e)) from e - if last_exc: # pragma: no cover - raise APIError(0, str(last_exc)) from last_exc - raise APIError(0, "Max retries exceeded") # pragma: no cover - - def list_tasks(self, **params: Any) -> list[dict[str, Any]]: - r = self._request("GET", "/tasks", params=params) - return r.json() - - def get_task(self, task_id: int) -> dict[str, Any]: - """Fetch a single task by its numeric ID.""" - r = self._request("GET", f"/tasks/{task_id}") - return r.json() - - def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]: - """List tasks in a specific project (more efficient than listing all tasks).""" - r = self._request("GET", f"/projects/{project_id}/tasks", params=params) - return r.json() - - def post_comment(self, task_id: int, comment: str) -> None: - self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment}) - - def update_task(self, task_id: int, **fields: Any) -> None: - self._request("POST", f"/tasks/{task_id}", json=fields) diff --git a/src/gitea_runner_manager/config.py b/src/gitea_runner_manager/config.py deleted file mode 100644 index 65e6a9f..0000000 --- a/src/gitea_runner_manager/config.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Shared configuration constants for GRM scripts and API clients.""" - -from __future__ import annotations - -import os -import re - -GITEA_API_URL = os.getenv("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") -VIKUNJA_API_URL = os.getenv("GRM_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1") - -REPO_OWNER = os.getenv("GRM_REPO_OWNER", "oblachno-oss") -REPO_NAME = os.getenv("GRM_REPO_NAME", "grm") - -VIKUNJA_PROJECT_ID = int(os.getenv("GRM_VIKUNJA_PROJECT_ID", "6")) - -TASK_ID_RE = re.compile(r"GRM-\d+") -CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+") - -DEFAULT_TIMEOUT = 30 -DEFAULT_PER_PAGE = 50 - -BRANCH_PROTECTION_CONFIG: dict[str, object] = { - "branch_name": "master", - "enable_push": True, - "enable_push_whitelist": True, - "push_whitelist_usernames": ["emil"], - "enable_status_check": True, - "status_check_contexts": [ - "CI / quality (pull_request)", - "CI / molecule-tests (1) (pull_request)", - "CI / molecule-tests (2) (pull_request)", - "CI / molecule-tests (3) (pull_request)", - ], - "required_approvals": 0, - "dismiss_stale_approvals": True, - "block_on_outdated_branch": True, - "block_on_rejected_reviews": True, - "block_on_official_review_requests": True, -} - -REPO_SETTINGS_CONFIG: dict[str, object] = { - "default_delete_branch_after_merge": True, -} diff --git a/src/gitea_runner_manager/exceptions.py b/src/gitea_runner_manager/exceptions.py index e246799..230c6bd 100644 --- a/src/gitea_runner_manager/exceptions.py +++ b/src/gitea_runner_manager/exceptions.py @@ -11,12 +11,3 @@ class AnsibleError(GRMError): """Raised when an Ansible command fails.""" pass - - -class APIError(GRMError): - """Raised when a REST API call returns an HTTP error.""" - - def __init__(self, status: int, message: str) -> None: - self.status = status - self.message = message - super().__init__(f"HTTP {status}: {message}") diff --git a/src/gitea_runner_manager/runner_manager.py b/src/gitea_runner_manager/runner_manager.py index d7f1d75..8998d1d 100644 --- a/src/gitea_runner_manager/runner_manager.py +++ b/src/gitea_runner_manager/runner_manager.py @@ -2,12 +2,11 @@ from __future__ import annotations -import contextlib import json import os import tempfile from collections.abc import Generator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from pathlib import Path from .exceptions import AnsibleError @@ -47,7 +46,7 @@ class RunnerManager: json.dump(extra_vars, f) yield path finally: - with contextlib.suppress(FileNotFoundError): + with suppress(FileNotFoundError): os.unlink(path) def _run_playbook( diff --git a/tests/unit/test_api_clients.py b/tests/unit/test_api_clients.py deleted file mode 100644 index 6fb3b19..0000000 --- a/tests/unit/test_api_clients.py +++ /dev/null @@ -1,642 +0,0 @@ -"""Unit tests for api_clients module.""" - -import http -from unittest.mock import MagicMock, patch - -import pytest -import requests - -from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error -from gitea_runner_manager.config import ( - BRANCH_PROTECTION_CONFIG, - DEFAULT_PER_PAGE, - DEFAULT_TIMEOUT, - VIKUNJA_PROJECT_ID, -) -from gitea_runner_manager.exceptions import APIError - - -def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock: - mock = MagicMock() - if json_data is not None: - mock.json.return_value = json_data - if raise_on_status: - mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR)) - return mock - - -def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError: - """Create an HTTPError with a proper response attached (for _parse_error).""" - resp = MagicMock() - resp.status_code = status_code - resp.json.return_value = {"message": message or str(status_code)} - err = requests.HTTPError(f"{status_code} {message}", response=resp) - return err - - -class TestParseError: - def test_json_parse_fallback(self) -> None: - mock_response = MagicMock() - mock_response.status_code = http.HTTPStatus.BAD_GATEWAY - mock_response.json = MagicMock(side_effect=ValueError("not json")) - err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response) - status, message = _parse_error(err) - assert status == http.HTTPStatus.BAD_GATEWAY - assert str(http.HTTPStatus.BAD_GATEWAY) in message - - def test_no_response(self) -> None: - err = requests.HTTPError("connection failed") - err.response = None # type: ignore[assignment] - status, message = _parse_error(err) - assert status == 0 - assert "connection failed" in message - - -class TestGiteaClient: - def test_init_sets_headers(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - assert client._base_url == "https://git.example.com" - assert client._session.headers["Authorization"] == "token tok" - assert client._session.headers["Content-Type"] == "application/json" - - def test_url_constructs_path(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels") - - def test_url_strips_trailing_slash(self) -> None: - client = GiteaClient("https://git.example.com/", "tok", "owner", "repo") - assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels") - - def test_list_labels(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}])) - result = client.list_labels() - assert len(result) == 1 - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/labels", - timeout=DEFAULT_TIMEOUT, - ) - - def test_list_labels_raises_api_error(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True)) - with pytest.raises(APIError): - client.list_labels() - - def test_http_error_json_parse_fallback(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - mock_response = MagicMock() - mock_response.status_code = http.HTTPStatus.BAD_GATEWAY - # Make json() itself raise so the except block in _parse_error is hit - mock_response.json = MagicMock(side_effect=ValueError("not json")) - mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY)) - client._session.request = MagicMock(return_value=mock_response) - with pytest.raises(APIError) as exc_info: - client.list_labels() - assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value) - - def test_create_label(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"})) - result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label") - assert result["name"] == "ready-to-merge" - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/labels", - timeout=DEFAULT_TIMEOUT, - json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"}, - ) - - def test_ensure_label_creates_when_not_exists(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client.list_labels = MagicMock(return_value=[]) - client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"}) - - result = client.ensure_label("ready-to-merge", "2ecc71", "desc") - assert result is not None - assert result["name"] == "ready-to-merge" - client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc") - - def test_ensure_label_returns_none_when_exists(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}]) - client.create_label = MagicMock() - - result = client.ensure_label("ready-to-merge", "2ecc71", "desc") - assert result is None - client.create_label.assert_not_called() - - def test_list_branch_protections(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock( - return_value=_mock_response( - [ - {"id": 1, "branch_name": "master"}, - {"id": 2, "branch_name": "develop"}, - ] - ) - ) - result = client.list_branch_protections() - assert len(result) == 2 - assert result[0]["branch_name"] == "master" - - def test_create_branch_protection(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"})) - result = client.create_branch_protection(BRANCH_PROTECTION_CONFIG) - assert result["id"] == 3 - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/branch_protections", - timeout=DEFAULT_TIMEOUT, - json=BRANCH_PROTECTION_CONFIG, - ) - - def test_update_branch_protection(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - resp = {"branch_name": "master", "required_approvals": 2} - client._session.request = MagicMock(return_value=_mock_response(resp)) - update = {"required_approvals": 2} - result = client.update_branch_protection("master", update) - assert result["required_approvals"] == 2 - client._session.request.assert_called_once_with( - "PATCH", - "https://git.example.com/repos/owner/repo/branch_protections/master", - timeout=DEFAULT_TIMEOUT, - json=update, - ) - - def test_ensure_branch_protection_creates_when_none_exist(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client.list_branch_protections = MagicMock(return_value=[]) - client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"}) - - result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) - assert result["id"] == 1 - client.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG) - - def test_ensure_branch_protection_updates_when_exists(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}]) - client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1}) - - result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) - assert result["required_approvals"] == 1 - expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"} - client.update_branch_protection.assert_called_once_with("master", expected_update) - - def test_merge_pr(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response()) - - client.merge_pr(1, "fix: bug") - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/pulls/1/merge", - timeout=DEFAULT_TIMEOUT, - json={"Do": "squash", "MergeTitleField": "fix: bug"}, - ) - - def test_get_pr_labels(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}])) - - result = client.get_pr_labels(5) - assert result == [{"name": "ready-to-merge"}] - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/issues/5/labels", - timeout=DEFAULT_TIMEOUT, - ) - - def test_get_commit_status(self) -> None: - """Uses combined status endpoint (/status, not /statuses).""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock( - return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]}) - ) - - result = client.get_commit_status("abc123") - assert result == [{"context": "CI / quality", "status": "success"}] - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/commits/abc123/status", - timeout=DEFAULT_TIMEOUT, - ) - - def test_get_pr(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}})) - - result = client.get_pr(7) - assert result["number"] == 7 - assert result["head"]["sha"] == "abc123" - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/pulls/7", - timeout=DEFAULT_TIMEOUT, - ) - - def test_get_pr_files(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock( - return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}]) - ) - - result = client.get_pr_files(7) - assert len(result) == 1 - assert result[0]["filename"] == "src/main.py" - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/pulls/7/files", - timeout=DEFAULT_TIMEOUT, - ) - - def test_get_pr_commits(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock( - return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}]) - ) - - result = client.get_pr_commits(7) - assert len(result) == 1 - assert result[0]["commit"]["message"] == "fix: bug" - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/pulls/7/commits", - timeout=DEFAULT_TIMEOUT, - ) - - def test_get_pr_reviews(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}])) - - result = client.get_pr_reviews(7) - assert len(result) == 1 - assert result[0]["state"] == "APPROVED" - client._session.request.assert_called_once_with( - "GET", - "https://git.example.com/repos/owner/repo/pulls/7/reviews", - timeout=DEFAULT_TIMEOUT, - ) - - def test_create_issue(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"})) - - result = client.create_issue(title="bug", body="description", labels=[1]) - assert result["id"] == 42 - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/issues", - json={"title": "bug", "body": "description", "labels": [1]}, - timeout=DEFAULT_TIMEOUT, - ) - - def test_create_issue_no_labels(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"})) - - result = client.create_issue(title="bug", body="description") - assert result["id"] == 43 - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/issues", - json={"title": "bug", "body": "description"}, - timeout=DEFAULT_TIMEOUT, - ) - - def test_create_review_comment(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 42})) - - result = client.create_review(7, event="COMMENT", body="Looks good") - assert result["id"] == 42 - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/pulls/7/reviews", - timeout=DEFAULT_TIMEOUT, - json={"event": "COMMENT", "body": "Looks good"}, - ) - - def test_create_review_approve_maps_to_approved(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"})) - - result = client.create_review(7, event="APPROVE", body="Good work") - assert result["id"] == 44 - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/pulls/7/reviews", - timeout=DEFAULT_TIMEOUT, - json={"event": "APPROVED", "body": "Good work"}, - ) - - def test_create_review_with_inline_comments(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 43})) - - comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}] - result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments) - assert result["id"] == 43 - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/pulls/7/reviews", - timeout=DEFAULT_TIMEOUT, - json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments}, - ) - - def test_update_repo_settings(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True})) - - settings = {"default_delete_branch_after_merge": True} - result = client.update_repo_settings(settings) - assert result["default_delete_branch_after_merge"] is True - client._session.request.assert_called_once_with( - "PATCH", - "https://git.example.com/repos/owner/repo", - timeout=DEFAULT_TIMEOUT, - json=settings, - ) - - def test_create_release(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 1})) - - client.create_release("v1.0.0") - client._session.request.assert_called_once_with( - "POST", - "https://git.example.com/repos/owner/repo/releases", - timeout=DEFAULT_TIMEOUT, - json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False}, - ) - - def test_get_release_by_tag_found(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"})) - result = client.get_release_by_tag("v1.0.0") - assert result is not None - assert result["id"] == 1 - - def test_get_release_by_tag_not_found(self) -> None: - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - mock_resp = MagicMock() - mock_resp.raise_for_status.side_effect = requests.HTTPError("404") - mock_resp.status_code = 404 - client._session.request = MagicMock(return_value=mock_resp) - result = client.get_release_by_tag("v9.9.9") - assert result is None - - def test_create_release_idempotent_existing(self) -> None: - """If release already exists, should return it without creating a new one.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"}) - client._session.request = MagicMock(return_value=existing_response) - result = client.create_release_idempotent("v1.0.0") - assert result["id"] == 42 - # Should only call GET (check), not POST (create) - assert client._session.request.call_count == 1 - assert client._session.request.call_args[0][0] == "GET" - - def test_create_release_idempotent_new(self) -> None: - """If release doesn't exist, should create it.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - not_found_resp = MagicMock() - not_found_resp.raise_for_status.side_effect = requests.HTTPError("404") - not_found_resp.status_code = 404 - create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"}) - client._session.request = MagicMock(side_effect=[not_found_resp, create_resp]) - result = client.create_release_idempotent("v1.0.0") - assert result["id"] == 1 - assert client._session.request.call_count == 2 - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None: - """Should retry on 429 rate limit with exponential backoff.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - rate_limited = MagicMock() - rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited") - success = _mock_response({"ok": True}) - client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success]) - result = client._request("GET", "/test") - assert result.json() == {"ok": True} - assert client._session.request.call_count == 3 - assert mock_sleep.call_count == 2 - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None: - """Should retry on 503 service unavailable.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - unavailable = MagicMock() - unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable") - success = _mock_response({"ok": True}) - client._session.request = MagicMock(side_effect=[unavailable, success]) - result = client._request("GET", "/test") - assert result.json() == {"ok": True} - assert client._session.request.call_count == 2 - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None: - """Should NOT retry on 404 — it's not a transient error.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - not_found = MagicMock() - not_found.raise_for_status.side_effect = _mock_http_error(404, "not found") - client._session.request = MagicMock(return_value=not_found) - with pytest.raises(APIError) as exc_info: - client._request("GET", "/test") - assert exc_info.value.status == 404 - assert client._session.request.call_count == 1 - mock_sleep.assert_not_called() - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None: - """Should retry on connection errors.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - success = _mock_response({"ok": True}) - client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success]) - result = client._request("GET", "/test") - assert result.json() == {"ok": True} - assert client._session.request.call_count == 2 - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None: - """Should raise APIError after max retries on persistent 503.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - unavailable = MagicMock() - unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable") - client._session.request = MagicMock(return_value=unavailable) - with pytest.raises(APIError) as exc_info: - client._request("GET", "/test") - assert exc_info.value.status == 503 - assert client._session.request.call_count == 3 # MAX_RETRIES - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None: - """Should raise APIError after max retries on persistent connection errors.""" - client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(side_effect=requests.ConnectionError("refused")) - with pytest.raises(APIError) as exc_info: - client._request("GET", "/test") - assert exc_info.value.status == 0 - assert client._session.request.call_count == 3 # MAX_RETRIES - - -class TestVikunjaClient: - def test_init_sets_headers(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - assert client._base_url == "https://work.example.com" - assert client._session.headers["Authorization"] == "Bearer tok" - - def test_list_tasks(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - client._session.request = MagicMock( - return_value=_mock_response([{"id": 1, "identifier": "GRM-19", "project_id": VIKUNJA_PROJECT_ID}]) - ) - - result = client.list_tasks(per_page=DEFAULT_PER_PAGE) - assert len(result) == 1 - client._session.request.assert_called_once_with( - "GET", - "https://work.example.com/tasks", - timeout=DEFAULT_TIMEOUT, - params={"per_page": DEFAULT_PER_PAGE}, - ) - - def test_list_project_tasks(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "GRM-19"}])) - - result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE) - assert len(result) == 1 - client._session.request.assert_called_once_with( - "GET", - f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks", - timeout=DEFAULT_TIMEOUT, - params={"page": 1, "per_page": DEFAULT_PER_PAGE}, - ) - - def test_get_task(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - client._session.request = MagicMock( - return_value=_mock_response({"id": 292, "identifier": "GRM-32", "title": "Some task"}) - ) - - result = client.get_task(292) - assert result["identifier"] == "GRM-32" - assert result["title"] == "Some task" - client._session.request.assert_called_once_with( - "GET", - "https://work.example.com/tasks/292", - timeout=DEFAULT_TIMEOUT, - ) - - def test_post_comment(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - client._session.request = MagicMock(return_value=_mock_response()) - - client.post_comment(42, "

hi

") - client._session.request.assert_called_once_with( - "PUT", - "https://work.example.com/tasks/42/comments", - timeout=DEFAULT_TIMEOUT, - json={"comment": "

hi

"}, - ) - - def test_update_task(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - client._session.request = MagicMock(return_value=_mock_response()) - - client.update_task(42, done=True) - client._session.request.assert_called_once_with( - "POST", - "https://work.example.com/tasks/42", - timeout=DEFAULT_TIMEOUT, - json={"done": True}, - ) - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None: - client = VikunjaClient("https://work.example.com", "tok") - mock_resp = MagicMock() - mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") - client._session.request = MagicMock(return_value=mock_resp) - - with pytest.raises(APIError): - client.list_tasks() - - def test_http_error_no_response(self) -> None: - client = VikunjaClient("https://work.example.com", "tok") - err = requests.HTTPError("connection failed") - err.response = None # type: ignore[assignment] - mock_resp = MagicMock() - mock_resp.raise_for_status.side_effect = err - client._session.request = MagicMock(return_value=mock_resp) - - with pytest.raises(APIError) as exc_info: - client.list_tasks() - assert "connection failed" in str(exc_info.value) - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None: - """VikunjaClient should also retry on 503.""" - client = VikunjaClient("https://work.example.com", "tok") - unavailable = MagicMock() - unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable") - success = _mock_response([{"id": 1}]) - client._session.request = MagicMock(side_effect=[unavailable, success]) - result = client.list_tasks() - assert len(result) == 1 - assert client._session.request.call_count == 2 - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None: - """VikunjaClient should retry on connection errors.""" - client = VikunjaClient("https://work.example.com", "tok") - success = _mock_response([{"id": 1}]) - client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success]) - result = client.list_tasks() - assert len(result) == 1 - assert client._session.request.call_count == 2 - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None: - """VikunjaClient should raise APIError after max retries on persistent 503.""" - client = VikunjaClient("https://work.example.com", "tok") - unavailable = MagicMock() - unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable") - client._session.request = MagicMock(return_value=unavailable) - with pytest.raises(APIError) as exc_info: - client.list_tasks() - assert exc_info.value.status == 503 - assert client._session.request.call_count == 3 # MAX_RETRIES - - @patch("gitea_runner_manager.api_clients.time.sleep") - def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None: - """VikunjaClient should raise APIError after max retries on persistent connection errors.""" - client = VikunjaClient("https://work.example.com", "tok") - client._session.request = MagicMock(side_effect=requests.ConnectionError("refused")) - with pytest.raises(APIError) as exc_info: - client.list_tasks() - assert exc_info.value.status == 0 - assert client._session.request.call_count == 3 # MAX_RETRIES - - -class TestIsRetryable: - def test_connection_error_is_retryable(self) -> None: - assert _is_retryable(requests.ConnectionError("refused")) is True - - def test_timeout_is_retryable(self) -> None: - assert _is_retryable(requests.Timeout("timed out")) is True - - def test_429_is_retryable(self) -> None: - err = _mock_http_error(429, "rate limited") - assert _is_retryable(err) is True - - def test_404_is_not_retryable(self) -> None: - err = _mock_http_error(404, "not found") - assert _is_retryable(err) is False - - def test_generic_exception_is_not_retryable(self) -> None: - assert _is_retryable(ValueError("oops")) is False diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py deleted file mode 100644 index bf822e8..0000000 --- a/tests/unit/test_config.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Unit tests for config module constants.""" - -from gitea_runner_manager.config import ( - BRANCH_PROTECTION_CONFIG, - CONVENTIONAL_RE, - DEFAULT_PER_PAGE, - DEFAULT_TIMEOUT, - GITEA_API_URL, - REPO_NAME, - REPO_OWNER, - TASK_ID_RE, - VIKUNJA_API_URL, - VIKUNJA_PROJECT_ID, -) - - -class TestConfigConstants: - def test_api_urls(self) -> None: - assert "api/v1" in GITEA_API_URL - assert "api/v1" in VIKUNJA_API_URL - - def test_project_ids(self) -> None: - assert VIKUNJA_PROJECT_ID == 6 - - def test_timeouts(self) -> None: - assert DEFAULT_TIMEOUT == 30 - assert DEFAULT_PER_PAGE == 50 - - def test_owner_and_repo(self) -> None: - assert REPO_OWNER == "oblachno-oss" - assert REPO_NAME == "grm" - - def test_task_id_re(self) -> None: - assert TASK_ID_RE.search("GRM-1") - assert TASK_ID_RE.search("GRM-123") - assert not TASK_ID_RE.search("GRM-") - assert not TASK_ID_RE.search("other text") - - def test_conventional_re(self) -> None: - assert CONVENTIONAL_RE.match("feat: add feature") - assert CONVENTIONAL_RE.match("fix(scope): bug fix") - assert not CONVENTIONAL_RE.match("random message") - assert not CONVENTIONAL_RE.match("feat:") - assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something") - - def test_branch_protection_config(self) -> None: - assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master" - assert BRANCH_PROTECTION_CONFIG["enable_push"] is True - assert BRANCH_PROTECTION_CONFIG["enable_push_whitelist"] is True - assert "emil" in BRANCH_PROTECTION_CONFIG["push_whitelist_usernames"] - assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 0 - contexts = BRANCH_PROTECTION_CONFIG["status_check_contexts"] - assert isinstance(contexts, list) - assert len(contexts) == 4 - assert "CI / quality (pull_request)" in contexts - assert any("molecule-tests" in c for c in contexts)