Compare commits

..
6 Commits
Author SHA1 Message Date
grm-ci-bot a4d5ba6b70 release: v0.12.0 [skip ci] 2026-06-28 13:15:08 +00:00
emil d9ce4e240f GRM-113: feat: upgrade all dependencies, add trigger-workflow command
Post-merge / vikunja (push) Successful in 1m13s
Post-merge / configure-repo (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m33s
Post-merge / detect-type (push) Successful in 55s
Post-merge / release (push) Successful in 1m19s
Post-merge / validate-commit-msg (push) Successful in 1m19s
Post-merge / publish (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m54s
2026-06-28 13:13:06 +00:00
gitea-actions-bot c1f68f115a chore: update badge URLs to commit dfe10dfc [skip ci] 2026-06-28 11:41:38 +00:00
grm-ci-bot fdf1293c85 release: v0.11.1 [skip ci] 2026-06-28 11:41:20 +00:00
emil 01b3f594f7 GRM-112: fix: Makefile HOST/NAME requirement errors, add restart and list targets
Post-merge / detect-type (push) Successful in 48s
Post-merge / release (push) Successful in 1m0s
Post-merge / validate-commit-msg (push) Successful in 1m3s
Post-merge / vikunja (push) Successful in 1m3s
Post-merge / badges (push) Successful in 1m11s
Post-merge / sync-wiki (push) Successful in 1m35s
Post-merge / configure-repo (push) Successful in 58s
Post-merge / publish (push) Successful in 57s
2026-06-28 11:39:37 +00:00
gitea-actions-bot 9ffa7a3671 chore: update badge URLs to commit cc0e4b3f [skip ci] 2026-06-28 11:23:32 +00:00
27 changed files with 548 additions and 1166 deletions
+7
View File
@@ -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
@@ -47,6 +50,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
+1
View File
@@ -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 }}
+1
View File
@@ -29,6 +29,7 @@ name: Post-merge
on:
push:
branches: [master]
workflow_dispatch:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
-7
View File
@@ -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
+8 -9
View File
@@ -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=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--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: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
--body "All 13 checklist categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
```
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
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## [0.12.0] - 2026-06-28
### Features
- Upgrade all dependencies, add trigger-workflow command
## [0.11.1] - 2026-06-28
### Bug Fixes
- Makefile HOST/NAME requirement errors, add restart and list targets
## [0.11.0] - 2026-06-28
### Features
+14 -7
View File
@@ -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
+10 -8
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/python.svg)](https://www.python.org/downloads/)
## Why GRM?
@@ -148,8 +148,11 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc
| `grm disable <name>` | Disable and deregister a runner |
| `grm status <name>` | Check the status of a registered runner |
| `grm remove <name>` | Remove a runner completely (with remote cleanup) |
| `grm remove <name> --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 <workflow_id>` | 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`.
@@ -360,8 +363,7 @@ grm install <host>
| `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 |
+4 -2
View File
@@ -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"
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/dfe10dfc2483ccdda434e9b067348921257fdffd/python.svg)](https://www.python.org/downloads/)
## Overview
+1 -3
View File
@@ -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
+2 -2
View File
@@ -155,7 +155,7 @@ Once all comments are addressed, post an approval review:
CI_GITEA_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--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`
+1 -1
View File
@@ -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]`)
-2
View File
@@ -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
+8 -12
View File
@@ -14,10 +14,9 @@ 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",
"click>=8.4.2",
"ansible>=14.1.0",
]
[project.scripts]
@@ -27,10 +26,10 @@ 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",
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
"build>=1.5.0",
"twine>=6.2.0",
@@ -39,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",
]
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.11.0"
__version__ = "0.12.0"
-353
View File
@@ -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)
+80
View File
@@ -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."))
-43
View File
@@ -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,
}
-9
View File
@@ -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}")
+86
View File
@@ -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)
+2 -3
View File
@@ -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(
@@ -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}"
}
}
-642
View File
@@ -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, "<p>hi</p>")
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
json={"comment": "<p>hi</p>"},
)
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
+88
View File
@@ -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")
-56
View File
@@ -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)
+120
View File
@@ -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