Compare commits

..
16 Commits
Author SHA1 Message Date
grm-ci-bot 8a41b1237d release: v0.14.3 [skip ci] 2026-07-06 06:08:28 +00:00
emil 34742bab40 GRM-136: refactor: rename PyPI package from gitea-runner-manager to grm
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m14s
Post-merge / validate-commit-msg (push) Successful in 1m25s
Post-merge / vikunja (push) Successful in 1m21s
Post-merge / badges (push) Successful in 1m45s
Post-merge / configure-repo (push) Successful in 1m15s
Post-merge / sync-wiki (push) Successful in 3m5s
Post-merge / publish (push) Successful in 1m1s
2026-07-06 06:06:13 +00:00
gitea-actions-bot d3dbb17cc2 chore: update badge URLs to commit 08460d07 [skip ci] 2026-07-06 05:21:16 +00:00
emil 751f594ce5 GRM-135: chore: bump devx to 0.33.3
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m1s
Post-merge / validate-commit-msg (push) Successful in 1m26s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m32s
Post-merge / configure-repo (push) Successful in 1m19s
Post-merge / vikunja (push) Successful in 1m31s
Post-merge / sync-wiki (push) Successful in 3m0s
2026-07-06 05:18:44 +00:00
gitea-actions-bot d8caebee2d chore: update badge URLs to commit 089dfa2e [skip ci] 2026-07-06 04:55:07 +00:00
emil bb4cc80a98 GRM-134: ci: add concurrency group and increase timeout for sync-wiki
Post-merge / detect-type (push) Successful in 1m0s
Post-merge / release (push) Successful in 47s
Post-merge / validate-commit-msg (push) Successful in 1m18s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m29s
Post-merge / vikunja (push) Successful in 1m12s
Post-merge / configure-repo (push) Successful in 1m12s
Post-merge / sync-wiki (push) Successful in 2m38s
2026-07-06 04:52:12 +00:00
gitea-actions-bot e0b2e64b8e chore: update badge URLs to commit f8b1b5df [skip ci] 2026-07-05 20:52:42 +00:00
emil 00404cb484 GRM-133: ci: rename doc-syncer to doc-sync-specialist for consistency
Post-merge / detect-type (push) Successful in 1m0s
Post-merge / release (push) Successful in 46s
Post-merge / validate-commit-msg (push) Successful in 1m15s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m23s
Post-merge / badges (push) Successful in 1m30s
Post-merge / configure-repo (push) Successful in 1m14s
Post-merge / sync-wiki (push) Successful in 3m8s
2026-07-05 20:49:16 +00:00
gitea-actions-bot 1a30b595dc chore: update badge URLs to commit ab3c0809 [skip ci] 2026-07-05 15:10:28 +00:00
grm-ci-bot 3538eb0803 release: v0.14.2 [skip ci] 2026-07-05 15:09:04 +00:00
emil 5a93559b79 GRM-132: ci: bump devx to 0.33.0, use devx-check-api-identity-checks
Post-merge / detect-type (push) Successful in 50s
Post-merge / validate-commit-msg (push) Successful in 1m5s
Post-merge / release (push) Successful in 1m11s
Post-merge / vikunja (push) Successful in 1m3s
Post-merge / badges (push) Successful in 1m17s
Post-merge / configure-repo (push) Successful in 1m3s
Post-merge / publish (push) Successful in 1m2s
Post-merge / sync-wiki (push) Successful in 2m38s
2026-07-05 15:07:03 +00:00
gitea-actions-bot e30acbe213 chore: update badge URLs to commit bdbbfb7b [skip ci] 2026-07-02 17:01:50 +00:00
emil 386f3a88c6 GRM-131: ci: remove redundant devx reinstall in doc lint step
Post-merge / detect-type (push) Successful in 1m3s
Post-merge / publish (push) Has been skipped
Post-merge / release (push) Successful in 57s
Post-merge / validate-commit-msg (push) Successful in 1m5s
Post-merge / sync-wiki (push) Successful in 2m42s
Post-merge / vikunja (push) Successful in 1m3s
Post-merge / configure-repo (push) Successful in 1m1s
Post-merge / badges (push) Successful in 1m13s
2026-07-02 16:58:01 +00:00
gitea-actions-bot e99e9d0ac8 chore: update badge URLs to commit 7e626586 [skip ci] 2026-07-01 23:40:46 +00:00
emil ca1d8e5cc0 GRM-130: fix: add pre-commit hooks for quality gates matching CI
Post-merge / detect-type (push) Successful in 1m8s
Post-merge / release (push) Successful in 1m15s
Post-merge / vikunja (push) Successful in 1m38s
Post-merge / validate-commit-msg (push) Successful in 1m44s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m49s
Post-merge / sync-wiki (push) Successful in 2m28s
Post-merge / configure-repo (push) Successful in 1m21s
2026-07-01 23:37:28 +00:00
gitea-actions-bot 83e800c900 chore: update badge URLs to commit b0888676 [skip ci] 2026-07-01 23:14:33 +00:00
47 changed files with 285 additions and 203 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ Focus on the FIRST error.
**Release failures:** **Release failures:**
- **git-cliff errors**: version calculation, no unreleased changes - **git-cliff errors**: version calculation, no unreleased changes
- **Lint/test during release**: release runs `make lint-ruff` and `make pytest-cov` - **Lint/test during release**: release runs `make lint-ruff` and `make pytest-cov`
- **Tag/commit misalignment**: check `src/gitea_runner_manager/__init__.py` version - **Tag/commit misalignment**: check `src/grm/__init__.py` version
**Publish failures:** **Publish failures:**
- **PyPI publish**: registry auth, package build errors - **PyPI publish**: registry auth, package build errors
@@ -1,5 +1,5 @@
--- ---
name: doc-syncer name: doc-sync-specialist
description: Handles documentation coverage, doc structure linting, and wiki sync for the grm repo. Detects missing docs, fixes broken links, updates mapping.json, and debugs wiki sync failures. description: Handles documentation coverage, doc structure linting, and wiki sync for the grm repo. Detects missing docs, fixes broken links, updates mapping.json, and debugs wiki sync failures.
model: glm-5.2 model: glm-5.2
allowed-tools: allowed-tools:
@@ -69,3 +69,24 @@ source activate.zsh # zsh
``` ```
If `.venv` doesn't exist, run `make setup` first. If `.venv` doesn't exist, run `make setup` first.
## Common Pitfalls
### Coverage Verification Before Push
**Always run `make pytest-cov` before pushing** — CI enforces 100%
coverage and will fail the PR if any lines are uncovered. This is the
most common cause of CI quality job failures after code changes. The
pre-push git hook only validates Vikunja task existence, not tests.
### API Response Type Checking
Never use `is True`/`is False` identity checks on API response values.
Many APIs return boolean values as strings (`"true"`/`"false"`). Use
string comparison or truthy/falsy helpers instead.
### Time Mocking in Tests
Always mock `time.sleep` and `time.monotonic` in unit tests using
`@patch` decorators. Real sleep calls make tests slow and exceed test
speed limits.
+1 -1
View File
@@ -60,4 +60,4 @@ DEVX_TASK_PREFIX=GRM
# Vikunja project ID for GRM # Vikunja project ID for GRM
DEVX_VIKUNJA_PROJECT_ID=6 DEVX_VIKUNJA_PROJECT_ID=6
# Version file path (relative to repo root) # Version file path (relative to repo root)
DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py DEVX_VERSION_FILE=src/grm/__init__.py
+3 -8
View File
@@ -20,7 +20,7 @@ jobs:
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=lint run: make setup-image EXTRAS=ci,lint
- name: Lint all - name: Lint all
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -33,18 +33,13 @@ jobs:
- name: Documentation lint check - name: Documentation lint check
env: env:
PYTHONPATH: src PYTHONPATH: src
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
pip install --upgrade devx \
--index-url "https://${CI_GITEA_USERNAME}:${CI_GITEA_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/" \
--no-deps
python3 -m devx.ci.lint_docs --root . python3 -m devx.ci.lint_docs --root .
- name: Translation completeness check - name: Translation completeness check
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations --translations src/gitea_runner_manager/translations.json python3 -m devx.ci.check_translations --translations src/grm/translations.json
- name: Check unit test speed - name: Check unit test speed
env: env:
PYTHONPATH: src PYTHONPATH: src
@@ -87,7 +82,7 @@ jobs:
- name: Release dry-run validation - name: Release dry-run validation
env: env:
PYTHONPATH: src PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
+5 -2
View File
@@ -110,7 +110,7 @@ jobs:
id: release-tag id: release-tag
env: env:
PYTHONPATH: src PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6 DEVX_VIKUNJA_PROJECT_ID: 6
run: | run: |
@@ -176,7 +176,10 @@ jobs:
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 15
concurrency:
group: sync-wiki-${{ github.repository }}
cancel-in-progress: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
+31
View File
@@ -57,6 +57,37 @@ repos:
pass_filenames: false pass_filenames: false
stages: [pre-commit] stages: [pre-commit]
- id: checkmake
name: checkmake Makefile linter
entry: make checkmake
language: system
files: ^Makefile$
pass_filenames: false
stages: [pre-commit]
- id: check-test-speed
name: unit test speed check
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: check-translations
name: translation completeness check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations --translations src/grm/translations.json
language: system
files: ^src/grm/translations\.json$
pass_filenames: false
stages: [pre-commit]
- id: lint-docs
name: documentation lint check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
language: system
pass_filenames: false
stages: [pre-commit]
- id: pytest-cov - id: pytest-cov
name: pytest with 100% coverage name: pytest with 100% coverage
entry: make pytest-cov entry: make pytest-cov
+30 -13
View File
@@ -56,7 +56,7 @@ CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is
## Architecture ## Architecture
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible - **Python CLI** (`src/grm/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup - **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications - **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits - **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
@@ -232,7 +232,7 @@ Vikunja task updates:
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version `AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes. bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
- Uses **git-cliff** to calculate the next semver version from conventional commits - Uses **git-cliff** to calculate the next semver version from conventional commits
- Updates `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth) - Updates `__version__` in `src/grm/__init__.py` (single source of truth)
- Updates `CHANGELOG.md` with the new version section - Updates `CHANGELOG.md` with the new version section
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy - **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **aborts immediately** — no commit, no tag - If lint or tests fail, **aborts immediately** — no commit, no tag
@@ -284,7 +284,7 @@ via `[tool.devx.classify]` in `pyproject.toml`.
- `activate.sh`, `activate.fish`, `activate.zsh` — Generated venv scripts - `activate.sh`, `activate.fish`, `activate.zsh` — Generated venv scripts
**User-facing paths** (tool changes → release needed) — everything else: **User-facing paths** (tool changes → release needed) — everything else:
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py`) - `src/grm/**` — Python CLI source (except `__init__.py`)
- `ansible/**` — Ansible role - `ansible/**` — Ansible role
- `pyproject.toml` — Package metadata - `pyproject.toml` — Package metadata
- Any new file type not in the allowlist - Any new file type not in the allowlist
@@ -318,14 +318,14 @@ The codebase enforces strict separation between the GRM tool and the devx packag
| Directory | Purpose | Release impact | | Directory | Purpose | Release impact |
|-----------|---------|----------------| |-----------|---------|----------------|
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release | | `src/grm/` | User-facing GRM CLI tool | Changes trigger release |
| `devx` package (installed from git) | Reusable CI/CD and dev tools | Not in this repo (no release impact) | | `devx` package (installed from git) | Reusable CI/CD and dev tools | Not in this repo (no release impact) |
| `ansible/` | Ansible role for runner setup | Changes trigger release | | `ansible/` | Ansible role for runner setup | Changes trigger release |
### Import Rules ### Import Rules
1. **`src/gitea_runner_manager/` NEVER imports from devx** — the GRM tool is self-contained 1. **`src/grm/` NEVER imports from devx** — the GRM tool is self-contained
2. **devx MAY import from `gitea_runner_manager`** — one-way dependency (devx uses the tool's API clients, config, i18n) 2. **devx MAY import from `grm`** — one-way dependency (devx uses the tool's API clients, config, i18n)
3. **Cross-module imports within devx** are allowed (devx modules importing from other devx modules) and must be documented 3. **Cross-module imports within devx** are allowed (devx modules importing from other devx modules) and must be documented
4. **`devx.gitea_cli`** is a shared wrapper around the `tea` CLI — devx modules import from it for Gitea API operations (issues, labels, PRs, releases, reviews) 4. **`devx.gitea_cli`** is a shared wrapper around the `tea` CLI — devx modules import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
@@ -355,11 +355,11 @@ The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is insta
### PYTHONPATH Configuration ### PYTHONPATH Configuration
Since devx is installed as a package (via `pip install` from git), it is importable directly. Workflows only need `PYTHONPATH=src` when a devx module imports from `gitea_runner_manager`: Since devx is installed as a package (via `pip install` from git), it is importable directly. Workflows only need `PYTHONPATH=src` when a devx module imports from `grm`:
| PYTHONPATH | When to use | Example modules | | PYTHONPATH | When to use | Example modules |
|------------|-------------|-----------------| |------------|-------------|-----------------|
| `src` | Module imports from `gitea_runner_manager` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.pr_review`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` | | `src` | Module imports from `grm` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.pr_review`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` |
| (none) | Module has no GRM imports | `devx.ci.detect_release_commit`, `devx.molecule.distribute_molecule`, `devx.molecule.molecule_ci_guard`, `devx.ci.push_badges`, `devx.ci.validate_commit_msg` | | (none) | Module has no GRM imports | `devx.ci.detect_release_commit`, `devx.molecule.distribute_molecule`, `devx.molecule.molecule_ci_guard`, `devx.ci.push_badges`, `devx.ci.validate_commit_msg` |
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`): **In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
@@ -370,7 +370,7 @@ Since devx is installed as a package (via `pip install` from git), it is importa
run: python -m devx.ci.example run: python -m devx.ci.example
``` ```
**Locally**, devx is installed as a package, so only `PYTHONPATH=src` is needed if importing from `gitea_runner_manager`. **Locally**, devx is installed as a package, so only `PYTHONPATH=src` is needed if importing from `grm`.
### Shared Constants ### Shared Constants
@@ -403,7 +403,7 @@ ensures all merged work appears in the changelog.
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) | | `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) | | `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version. The version source is `__version__` in `src/grm/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version.
### Title Format Summary ### Title Format Summary
@@ -419,7 +419,7 @@ The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, r
The devx package is configured via `DEVX_*` environment variables: The devx package is configured via `DEVX_*` environment variables:
- `DEVX_TASK_PREFIX=GRM` — Prefix for Vikunja task identifiers - `DEVX_TASK_PREFIX=GRM` — Prefix for Vikunja task identifiers
- `DEVX_VIKUNJA_PROJECT_ID=6` — Vikunja project ID for task tracking - `DEVX_VIKUNJA_PROJECT_ID=6` — Vikunja project ID for task tracking
- `DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py` — Path to the version source file - `DEVX_VERSION_FILE=src/grm/__init__.py` — Path to the version source file
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the infrastructure and user-facing path patterns. Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the infrastructure and user-facing path patterns.
@@ -433,6 +433,23 @@ Change classification is config-driven via `[tool.devx.classify]` in `pyproject.
- Secrets are passed via temp JSON files, never on the command line (CWE-214) - Secrets are passed via temp JSON files, never on the command line (CWE-214)
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`) - CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
### Testing Conventions
- **Always run `make pytest-cov` before pushing** — CI enforces 100%
coverage and will fail the PR if any lines are uncovered. The pre-push
hook only validates Vikunja task existence, not tests.
- **Never use `is True`/`is False` identity checks on API response
values** — many APIs return boolean values as strings (`"true"`/
`"false"`). Use string comparison or truthy/falsy helpers instead.
- **Always mock `time.sleep` and `time.monotonic` in unit tests** — real
sleep calls make tests slow and exceed test speed limits. Use
`@patch("time.sleep")` and `@patch("time.monotonic")` decorators.
- **Extract complex inline shell from workflows to tested Python tools**
— SSH loops, curl polling, docker exec chains, and multi-line
if/then/else shell blocks should be Python scripts in `scripts/`
with unit tests. Simple variable checks and venv activation are fine
as inline shell.
### Container-Level Fix Verification (Mandatory) ### Container-Level Fix Verification (Mandatory)
**Rule:** Before pushing any fix that modifies container state (CA certs, **Rule:** Before pushing any fix that modifies container state (CA certs,
@@ -552,7 +569,7 @@ the user should not need to specify which profile to use.
| `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) | | `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) |
| `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail | | `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification | | `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
| `doc-syncer` | Doc coverage, doc linting, wiki sync for grm docs | | `doc-sync-specialist` | Doc coverage, doc linting, wiki sync for grm docs |
| `workflow-validator` | actionlint + act_runner dry-run for grm workflows | | `workflow-validator` | actionlint + act_runner dry-run for grm workflows |
### When to Delegate Automatically ### When to Delegate Automatically
@@ -563,7 +580,7 @@ the user should not need to specify which profile to use.
| PR ready for review | `pr-reviewer` | Foreground | | PR ready for review | `pr-reviewer` | Foreground |
| Molecule tests need to run | `molecule-runner` | Background | | Molecule tests need to run | `molecule-runner` | Background |
| Dependency upgrade requested | `dep-upgrader` | Background | | Dependency upgrade requested | `dep-upgrader` | Background |
| Doc coverage failure or wiki sync issue | `doc-syncer` | Background | | Doc coverage failure or wiki sync issue | `doc-sync-specialist` | Background |
| Workflow YAML modified or validation needed | `workflow-validator` | Background | | Workflow YAML modified or validation needed | `workflow-validator` | Background |
| Branch ready for merge | `release-check` | Foreground | | Branch ready for merge | `release-check` | Foreground |
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.14.3] - 2026-07-06
### Refactor
- Rename PyPI package from gitea-runner-manager to grm
## [0.14.2] - 2026-07-05
### Bug Fixes
- Add pre-commit hooks for quality gates matching CI
## [0.14.1] - 2026-07-01 ## [0.14.1] - 2026-07-01
### Refactor ### Refactor
+7 -4
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release setup-image install update lint ansible-lint makefile-lint lint-all lint-ruff lint-format lint-bandit lint-deps typecheck checkmake install-hooks test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools .PHONY: all setup setup-ci setup-quality setup-molecule setup-release setup-image install update lint ansible-lint makefile-lint lint-all lint-ruff lint-format lint-bandit lint-deps typecheck checkmake install-hooks test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools check-api-identity-checks
.PHONY: configure-gitea-pypi .PHONY: configure-gitea-pypi
.PHONY: create-task create-pr push-with-pr git-push .PHONY: create-task create-pr push-with-pr git-push
@@ -14,7 +14,7 @@ all: setup
DEVX_PYTHON := $(BIN)/python DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV) DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN) DEVX_BIN := $(BIN)
DEVX_COV_PKG := src/gitea_runner_manager DEVX_COV_PKG := src/grm
DEVX_TEST_PATHS := tests/ scripts/tests/ DEVX_TEST_PATHS := tests/ scripts/tests/
DEVX_LINT_PATHS := src/ scripts/ tests/ DEVX_LINT_PATHS := src/ scripts/ tests/
@@ -152,7 +152,7 @@ test-unit: devx-test-unit
# Override devx-pytest-cov to cover both src/ and scripts/ # Override devx-pytest-cov to cover both src/ and scripts/
pytest-cov: pytest-cov:
@$(BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100 @$(BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=src/grm --cov=scripts --cov-report=term-missing --cov-fail-under=100
workflow-lint: devx-workflow-lint workflow-lint: devx-workflow-lint
workflow-dryrun: devx-workflow-dryrun workflow-dryrun: devx-workflow-dryrun
workflow-check: devx-workflow-check workflow-check: devx-workflow-check
@@ -173,7 +173,10 @@ makefile-lint:
echo "checkmake not found, skipping Makefile lint"; \ echo "checkmake not found, skipping Makefile lint"; \
fi fi
lint-all: lint ansible-lint makefile-lint workflow-lint lint-all: lint ansible-lint makefile-lint workflow-lint check-api-identity-checks
check-api-identity-checks:
@$(BIN)/python -m devx.tools.check_api_identity_checks
test-integration: test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov $(BIN)/pytest tests/integration/ -v --no-cov
+9 -9
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) [![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) [![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/627be1f191b334de9f4f83426ae3cbe6810ff69a/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/python.svg)](https://www.python.org/downloads/)
## Why GRM? ## Why GRM?
@@ -99,7 +99,7 @@ The registry is publicly readable — no authentication required to install.
**Quick install (one-off):** **Quick install (one-off):**
```bash ```bash
pip install gitea-runner-manager --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple pip install grm --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
``` ```
**Persistent configuration (recommended):** **Persistent configuration (recommended):**
@@ -115,7 +115,7 @@ extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/py
Then install normally: Then install normally:
```bash ```bash
pip install gitea-runner-manager pip install grm
``` ```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks This installs the `grm` CLI and its Python dependencies. The Ansible playbooks
@@ -339,7 +339,7 @@ See the [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/w
GRM consists of two layers: GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list. 1. **Python CLI** (`src/grm/`) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer. 2. **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
+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) [![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) [![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/627be1f191b334de9f4f83426ae3cbe6810ff69a/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/627be1f191b334de9f4f83426ae3cbe6810ff69a/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/08460d0769c8abe19fe57c7dec4e097463392e32/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
+3 -3
View File
@@ -2,7 +2,7 @@
GRM consists of two layers: GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. 1. **Python CLI** (`src/grm/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea. 2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
## High-Level Design ## High-Level Design
@@ -95,7 +95,7 @@ Lingering is enabled via `loginctl enable-linger` so the user's systemd services
```mermaid ```mermaid
flowchart TD flowchart TD
CLI["Python CLI<br/>src/gitea_runner_manager/<br/>(Click)"] CLI["Python CLI<br/>src/grm/<br/>(Click)"]
RM["RunnerManager<br/>runner_manager.py"] RM["RunnerManager<br/>runner_manager.py"]
EXEC["Executor<br/>executor.py"] EXEC["Executor<br/>executor.py"]
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"] REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
@@ -207,7 +207,7 @@ From `AGENTS.md`, the project also includes:
## Python Modules ## Python Modules
The Python CLI layer (`src/gitea_runner_manager/`) consists of the following modules: The Python CLI layer (`src/grm/`) consists of the following modules:
| Module | Description | | Module | Description |
|--------|-------------| |--------|-------------|
+3 -3
View File
@@ -158,7 +158,7 @@ After a PR is merged to master, the release pipeline runs automatically.
- Runs `devx.ci.release` which uses **git-cliff** to: - Runs `devx.ci.release` which uses **git-cliff** to:
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only workflow/infrastructure files changed, the release is **skipped entirely** — no version bump, no tag, no publish - **Checks for user-facing changes** via `devx.ci.classify_changes` — if only workflow/infrastructure files changed, the release is **skipped entirely** — no version bump, no tag, no publish
- Calculate the next semver version from conventional commits since the last tag - Calculate the next semver version from conventional commits since the last tag
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth) - Update `__version__` in `src/grm/__init__.py` (single source of truth)
- Update `CHANGELOG.md` with the new version section - Update `CHANGELOG.md` with the new version section
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy - **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **abort immediately** — no commit, no tag - If lint or tests fail, **abort immediately** — no commit, no tag
@@ -208,7 +208,7 @@ from accidentally skipping releases. Classification is config-driven via
`[tool.devx.classify]` in `pyproject.toml`. `[tool.devx.classify]` in `pyproject.toml`.
**User-facing paths** (tool changes → release needed): **User-facing paths** (tool changes → release needed):
- `src/gitea_runner_manager/**` — Python CLI source - `src/grm/**` — Python CLI source
- `ansible/**` — Ansible role - `ansible/**` — Ansible role
- `pyproject.toml` — Package metadata - `pyproject.toml` — Package metadata
@@ -322,7 +322,7 @@ From `cliff.toml` `[bump]` section:
- `breaking_always_bump_major = false` - `breaking_always_bump_major = false`
- `initial_tag = "0.1.0"` - `initial_tag = "0.1.0"`
The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version. The version source is `__version__` in `src/grm/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version.
## Title Format Summary ## Title Format Summary
+3 -3
View File
@@ -27,9 +27,9 @@
- **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`). - **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake + actionlint - **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake + actionlint
- **Formatting**: `ruff format` with double quotes and space indentation - **Formatting**: `ruff format` with double quotes and space indentation
- **Type checking**: `pyright` in strict mode for `src/gitea_runner_manager/` - **Type checking**: `pyright` in strict mode for `src/grm/`
- **Security scanning**: `bandit -r src/` on every PR - **Security scanning**: `bandit -r src/` on every PR
- **Import rules**: `src/gitea_runner_manager/` NEVER imports from devx — the GRM tool is self-contained - **Import rules**: `src/grm/` NEVER imports from devx — the GRM tool is self-contained
## Commit Rules ## Commit Rules
@@ -216,7 +216,7 @@ Not all changes require a new release. The project classifies changes using `dev
- Lint config files, `.env.example`, `.gitignore` - Lint config files, `.env.example`, `.gitignore`
**User-facing paths** (release needed): **User-facing paths** (release needed):
- `src/gitea_runner_manager/**` (except `__init__.py`) - `src/grm/**` (except `__init__.py`)
- `ansible/**` - `ansible/**`
- `pyproject.toml` - `pyproject.toml`
+5 -5
View File
@@ -8,7 +8,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Date:** 2026-06-21 (v0.2.0 unreleased) **Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`. **Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/grm/__init__.py`.
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`devx.ci.release`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release. **Rationale:** `__init__.py` is the single source of truth for the version. The release script (`devx.ci.release`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
@@ -84,7 +84,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Rationale:** Passing secrets as command-line arguments (e.g., `--extra-vars '{"token": "..."}'`) makes them visible in the process list (`ps aux`), which is a known security weakness (CWE-214). The `RunnerManager._extra_vars_file()` context manager writes extra-vars to a temporary file via `tempfile.mkstemp()`, sets permissions to `0600`, passes the file to Ansible via `--extra-vars @tempfile`, and deletes the file in a `finally` block — even if an exception occurs. This ensures secrets are never visible in the process list. **Rationale:** Passing secrets as command-line arguments (e.g., `--extra-vars '{"token": "..."}'`) makes them visible in the process list (`ps aux`), which is a known security weakness (CWE-214). The `RunnerManager._extra_vars_file()` context manager writes extra-vars to a temporary file via `tempfile.mkstemp()`, sets permissions to `0600`, passes the file to Ansible via `--extra-vars @tempfile`, and deletes the file in a `finally` block — even if an exception occurs. This ensures secrets are never visible in the process list.
**Source:** `AGENTS.md` (Key Conventions), `src/gitea_runner_manager/runner_manager.py` (`_extra_vars_file` method) **Source:** `AGENTS.md` (Key Conventions), `src/grm/runner_manager.py` (`_extra_vars_file` method)
--- ---
@@ -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. **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 `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/grm/**` (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]`) **Source:** `AGENTS.md` (Smart CI: User-Facing vs Workflow-Only Changes), `pyproject.toml` (`[tool.devx.classify]`)
@@ -104,9 +104,9 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Date:** 2026-06-21 (v0.6.2) **Date:** 2026-06-21 (v0.6.2)
**Decision:** Separate CI/CD and development tooling into the `devx` package (installed from git), keeping the GRM tool itself self-contained in `src/gitea_runner_manager/`. **Decision:** Separate CI/CD and development tooling into the `devx` package (installed from git), keeping the GRM tool itself self-contained in `src/grm/`.
**Rationale:** The GRM CLI tool must be self-contained — it never imports from devx. This ensures the installed package has no dependency on CI infrastructure. devx MAY import from `gitea_runner_manager` (one-way dependency), as it uses the tool's API clients, config, and i18n for CI automation. Cross-module imports within devx are allowed. This separation was formalised when scripts were migrated from the `scripts/` directory to the devx package in GRM-64. **Rationale:** The GRM CLI tool must be self-contained — it never imports from devx. This ensures the installed package has no dependency on CI infrastructure. devx MAY import from `grm` (one-way dependency), as it uses the tool's API clients, config, and i18n for CI automation. Cross-module imports within devx are allowed. This separation was formalised when scripts were migrated from the `scripts/` directory to the devx package in GRM-64.
**Source:** `AGENTS.md` (Source Code Separation and devx Integration), `CHANGELOG.md` (0.6.2 — Refactor: "Migrate from scripts/ to devx package") **Source:** `AGENTS.md` (Source Code Separation and devx Integration), `CHANGELOG.md` (0.6.2 — Refactor: "Migrate from scripts/ to devx package")
+2 -2
View File
@@ -4,7 +4,7 @@
``` ```
. .
├── src/gitea_runner_manager/ # Python CLI source ├── src/grm/ # Python CLI source
│ ├── cli.py # Click commands │ ├── cli.py # Click commands
│ ├── runner_manager.py # Ansible orchestration + registry integration │ ├── runner_manager.py # Ansible orchestration + registry integration
│ ├── executor.py # Ansible subprocess execution │ ├── executor.py # Ansible subprocess execution
@@ -172,7 +172,7 @@ make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement make pytest-cov # With 100% coverage enforcement
``` ```
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`. The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/grm/`.
### Integration tests ### Integration tests
+2 -2
View File
@@ -14,9 +14,9 @@ Runs pytest with 100% coverage requirement.
From the `Makefile`: From the `Makefile`:
- `test-unit``pytest tests/unit/ -v --no-cov` (unit tests without coverage) - `test-unit``pytest tests/unit/ -v --no-cov` (unit tests without coverage)
- `pytest-cov``pytest tests/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement) - `pytest-cov``pytest tests/ -v --cov=src/grm --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release. The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/grm/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release.
### Test speed verification ### Test speed verification
+1 -1
View File
@@ -374,7 +374,7 @@ Show the installed GRM version.
grm --version grm --version
``` ```
This reports the version from `__version__` in `src/gitea_runner_manager/__init__.py`, which is the single source of truth set by the automated release pipeline. This reports the version from `__version__` in `src/grm/__init__.py`, which is the single source of truth set by the automated release pipeline.
## Environment Variables ## Environment Variables
+1 -1
View File
@@ -156,7 +156,7 @@ Yes. GRM is designed with security as a first-class concern:
Yes: Yes:
```bash ```bash
pip install gitea-runner-manager pip install grm
``` ```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks and role are bundled with the package. For development or access to Make targets, clone the repository instead. This installs the `grm` CLI and its Python dependencies. The Ansible playbooks and role are bundled with the package. For development or access to Make targets, clone the repository instead.
+2 -2
View File
@@ -58,7 +58,7 @@ The registry is publicly readable — no authentication required to install.
**Quick install (one-off):** **Quick install (one-off):**
```bash ```bash
pip install gitea-runner-manager --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple pip install grm --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
``` ```
**Persistent configuration (recommended):** **Persistent configuration (recommended):**
@@ -73,7 +73,7 @@ extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/py
Then install normally: Then install normally:
```bash ```bash
pip install gitea-runner-manager pip install grm
``` ```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks This installs the `grm` CLI and its Python dependencies. The Ansible playbooks
+1 -1
View File
@@ -36,7 +36,7 @@ elif [ -x "${HOME}/.pyenv/bin/pyenv" ]; then
export PATH="${PYENV_ROOT}/bin:${PYENV_ROOT}/shims:${PATH}" export PATH="${PYENV_ROOT}/bin:${PYENV_ROOT}/shims:${PATH}"
eval "$("${PYENV_ROOT}/bin/pyenv" init -)" 2>/dev/null || true eval "$("${PYENV_ROOT}/bin/pyenv" init -)" 2>/dev/null || true
eval "$("${PYENV_ROOT}/bin/pyenv" virtualenv-init -)" 2>/dev/null || true eval "$("${PYENV_ROOT}/bin/pyenv" virtualenv-init -)" 2>/dev/null || true
pyenv activate gitea-runner-manager 2>/dev/null || true pyenv activate grm 2>/dev/null || true
PY=python3 PY=python3
else else
PY=python3 PY=python3
+10 -10
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "gitea-runner-manager" name = "grm"
dynamic = ["version"] dynamic = ["version"]
description = "Lean CLI to manage Gitea Actions runners" description = "Lean CLI to manage Gitea Actions runners"
readme = "README.md" readme = "README.md"
@@ -20,10 +20,10 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
grm = "gitea_runner_manager.cli:cli" grm = "grm.cli:cli"
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = {attr = "gitea_runner_manager.__version__"} version = {attr = "grm.__version__"}
[project.optional-dependencies] [project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv # Minimal deps for CI scripts that only need click/dotenv
@@ -34,7 +34,7 @@ ci = [
"build==1.5.0", "build==1.5.0",
"twine==6.2.0", "twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.) # Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx==0.32.0", "devx==0.33.3",
] ]
# Lint and type-checking tools (quality job) # Lint and type-checking tools (quality job)
lint = [ lint = [
@@ -52,9 +52,9 @@ molecule = [
] ]
# Full dev environment (local development, includes everything) # Full dev environment (local development, includes everything)
dev = [ dev = [
"gitea-runner-manager[ci,lint,molecule]", "grm[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr) # Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx==0.32.0", "devx==0.33.3",
# Non-Python dev dependency: checkmake (Makefile linter) # Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest # Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
] ]
@@ -63,12 +63,12 @@ dev = [
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
gitea_runner_manager = ["translations.json"] grm = ["translations.json"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests", "scripts/tests"] testpaths = ["tests", "scripts/tests"]
pythonpath = ["src", "scripts"] pythonpath = ["src", "scripts"]
addopts = "--cov=src/gitea_runner_manager --cov=scripts/prune_runner_images.py --cov-report=term-missing --cov-fail-under=100" addopts = "--cov=src/grm --cov=scripts/prune_runner_images.py --cov-report=term-missing --cov-fail-under=100"
markers = [ markers = [
"integration: marks tests as integration tests (not counted in coverage)", "integration: marks tests as integration tests (not counted in coverage)",
] ]
@@ -88,7 +88,7 @@ indent-style = "space"
[tool.pyright] [tool.pyright]
include = ["src"] include = ["src"]
pythonVersion = "3.12" pythonVersion = "3.12"
strict = ["src/gitea_runner_manager"] strict = ["src/grm"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Change classification — determines which changes trigger a release # Change classification — determines which changes trigger a release
@@ -128,7 +128,7 @@ infrastructure = ["scripts/**"]
# but are actually infrastructure: # but are actually infrastructure:
# - __init__.py: only contains __version__ (set by release.py, not user code) # - __init__.py: only contains __version__ (set by release.py, not user code)
infrastructure_overrides = [ infrastructure_overrides = [
"src/gitea_runner_manager/__init__.py", "src/grm/__init__.py",
] ]
# User-facing overrides — safety override for broad infrastructure patterns # User-facing overrides — safety override for broad infrastructure patterns
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners.""" """Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.14.1" __version__ = "0.14.3"
+2 -2
View File
@@ -5,14 +5,14 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from gitea_runner_manager.cli import cli from grm.cli import cli
@pytest.mark.integration @pytest.mark.integration
class TestLifecycleCLI: class TestLifecycleCLI:
"""Test the full lifecycle CLI commands end-to-end.""" """Test the full lifecycle CLI commands end-to-end."""
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_start_status_stop_disable_remove(self, mock_manager_class: MagicMock) -> None: def test_install_start_status_stop_disable_remove(self, mock_manager_class: MagicMock) -> None:
"""Exercise the full lifecycle via CLI.""" """Exercise the full lifecycle via CLI."""
mock_manager = MagicMock() mock_manager = MagicMock()
+3 -3
View File
@@ -5,14 +5,14 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from gitea_runner_manager.cli import cli from grm.cli import cli
@pytest.mark.integration @pytest.mark.integration
class TestMultiInstanceCLI: class TestMultiInstanceCLI:
"""Test that multiple runner instances can be managed independently.""" """Test that multiple runner instances can be managed independently."""
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_two_instances(self, mock_manager_class: MagicMock) -> None: def test_install_two_instances(self, mock_manager_class: MagicMock) -> None:
"""Install two named instances on the same host.""" """Install two named instances on the same host."""
mock_manager = MagicMock() mock_manager = MagicMock()
@@ -34,7 +34,7 @@ class TestMultiInstanceCLI:
assert calls[0].kwargs["name"] == "runner-a" assert calls[0].kwargs["name"] == "runner-a"
assert calls[1].kwargs["name"] == "runner-b" assert calls[1].kwargs["name"] == "runner-b"
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_start_stop_one_instance(self, mock_manager_class: MagicMock) -> None: def test_start_stop_one_instance(self, mock_manager_class: MagicMock) -> None:
"""Start one instance and stop another independently.""" """Start one instance and stop another independently."""
mock_manager = MagicMock() mock_manager = MagicMock()
+82 -82
View File
@@ -6,8 +6,8 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from gitea_runner_manager import __version__ from grm import __version__
from gitea_runner_manager.cli import cli from grm.cli import cli
_TEST_ENV = {"GITEA_URL": "https://git.example.com", "CI_GITEA_TOKEN": ""} _TEST_ENV = {"GITEA_URL": "https://git.example.com", "CI_GITEA_TOKEN": ""}
@@ -27,7 +27,7 @@ class TestCLI:
assert result.exit_code == 0 assert result.exit_code == 0
assert __version__ in result.output assert __version__ in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install(self, mock_manager_class: MagicMock) -> None: def test_install(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -50,7 +50,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_no_ask_become_pass(self, mock_manager_class: MagicMock) -> None: def test_install_no_ask_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -73,10 +73,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_missing_url(self, mock_manager_class: MagicMock) -> None: def test_install_missing_url(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.install.side_effect = AnsibleError("GITEA_URL must be set (or pass --url)") mock_manager.install.side_effect = AnsibleError("GITEA_URL must be set (or pass --url)")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -87,7 +87,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "GITEA_URL must be set" in result.output assert "GITEA_URL must be set" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_with_url_flag(self, mock_manager_class: MagicMock) -> None: def test_install_with_url_flag(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -114,10 +114,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_missing_token(self, mock_manager_class: MagicMock) -> None: def test_install_missing_token(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.install.side_effect = AnsibleError("GITEA_REGISTRATION_TOKEN must be set (or pass --token)") mock_manager.install.side_effect = AnsibleError("GITEA_REGISTRATION_TOKEN must be set (or pass --token)")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -128,7 +128,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "GITEA_REGISTRATION_TOKEN must be set" in result.output assert "GITEA_REGISTRATION_TOKEN must be set" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_with_options(self, mock_manager_class: MagicMock) -> None: def test_install_with_options(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -165,7 +165,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_ask_become_pass(self, mock_manager_class: MagicMock) -> None: def test_install_ask_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -188,10 +188,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_error(self, mock_manager_class: MagicMock) -> None: def test_install_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.install.side_effect = AnsibleError("fail") mock_manager.install.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -201,7 +201,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_with_labels(self, mock_manager_class: MagicMock) -> None: def test_install_with_labels(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -226,7 +226,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_with_empty_labels(self, mock_manager_class: MagicMock) -> None: def test_install_with_empty_labels(self, mock_manager_class: MagicMock) -> None:
"""Explicit empty string labels means 'no labels' (not 'use default').""" """Explicit empty string labels means 'no labels' (not 'use default')."""
mock_manager = MagicMock() mock_manager = MagicMock()
@@ -250,7 +250,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_labels_from_env(self, mock_manager_class: MagicMock) -> None: def test_install_labels_from_env(self, mock_manager_class: MagicMock) -> None:
"""Labels read from GITEA_RUNNER_LABELS env var when --labels not passed.""" """Labels read from GITEA_RUNNER_LABELS env var when --labels not passed."""
mock_manager = MagicMock() mock_manager = MagicMock()
@@ -274,7 +274,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_with_become_password_file(self, mock_manager_class: MagicMock) -> None: def test_install_with_become_password_file(self, mock_manager_class: MagicMock) -> None:
"""--become-password-file passes file path to manager.""" """--become-password-file passes file path to manager."""
import tempfile import tempfile
@@ -311,7 +311,7 @@ class TestCLI:
os.unlink(pw_file) os.unlink(pw_file)
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_install_verbose(self, mock_manager_class: MagicMock) -> None: def test_install_verbose(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -334,7 +334,7 @@ class TestCLI:
verbose=True, verbose=True,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_update(self, mock_manager_class: MagicMock) -> None: def test_update(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -364,7 +364,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_update_ask_become_pass(self, mock_manager_class: MagicMock) -> None: def test_update_ask_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -382,10 +382,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_update_error(self, mock_manager_class: MagicMock) -> None: def test_update_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.update.side_effect = AnsibleError("fail") mock_manager.update.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -395,7 +395,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_start(self, mock_manager_class: MagicMock) -> None: def test_start(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -413,7 +413,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_start_with_override(self, mock_manager_class: MagicMock) -> None: def test_start_with_override(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -431,7 +431,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_stop(self, mock_manager_class: MagicMock) -> None: def test_stop(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -449,7 +449,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_restart(self, mock_manager_class: MagicMock) -> None: def test_restart(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -467,7 +467,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_enable(self, mock_manager_class: MagicMock) -> None: def test_enable(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -485,7 +485,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_disable(self, mock_manager_class: MagicMock) -> None: def test_disable(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -505,10 +505,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_disable_missing_url(self, mock_manager_class: MagicMock) -> None: def test_disable_missing_url(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.disable.side_effect = AnsibleError("GITEA_URL must be set (or pass --url)") mock_manager.disable.side_effect = AnsibleError("GITEA_URL must be set (or pass --url)")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -519,7 +519,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "GITEA_URL must be set" in result.output assert "GITEA_URL must be set" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_disable_with_url_flag(self, mock_manager_class: MagicMock) -> None: def test_disable_with_url_flag(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -540,10 +540,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_disable_error(self, mock_manager_class: MagicMock) -> None: def test_disable_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.disable.side_effect = AnsibleError("fail") mock_manager.disable.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -553,7 +553,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_status(self, mock_manager_class: MagicMock) -> None: def test_status(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -571,7 +571,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_remove(self, mock_manager_class: MagicMock) -> None: def test_remove(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -592,7 +592,7 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_remove_force_flag(self, mock_manager_class: MagicMock) -> None: def test_remove_force_flag(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -613,10 +613,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_start_error(self, mock_manager_class: MagicMock) -> None: def test_start_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.start.side_effect = AnsibleError("fail") mock_manager.start.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -626,10 +626,10 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_stop_error(self, mock_manager_class: MagicMock) -> None: def test_stop_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.stop.side_effect = AnsibleError("fail") mock_manager.stop.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -639,10 +639,10 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_enable_error(self, mock_manager_class: MagicMock) -> None: def test_enable_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.enable.side_effect = AnsibleError("fail") mock_manager.enable.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -652,10 +652,10 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_status_error(self, mock_manager_class: MagicMock) -> None: def test_status_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.status.side_effect = AnsibleError("fail") mock_manager.status.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -665,10 +665,10 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_remove_missing_url(self, mock_manager_class: MagicMock) -> None: def test_remove_missing_url(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.remove.side_effect = AnsibleError("GITEA_URL must be set (or pass --url)") mock_manager.remove.side_effect = AnsibleError("GITEA_URL must be set (or pass --url)")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -679,7 +679,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "GITEA_URL must be set" in result.output assert "GITEA_URL must be set" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_remove_with_url_flag(self, mock_manager_class: MagicMock) -> None: def test_remove_with_url_flag(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -701,10 +701,10 @@ class TestCLI:
verbose=False, verbose=False,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_remove_error(self, mock_manager_class: MagicMock) -> None: def test_remove_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.remove.side_effect = AnsibleError("fail") mock_manager.remove.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -714,7 +714,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list(self, mock_manager_class: MagicMock) -> None: def test_list(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.list_runners.return_value = [ mock_manager.list_runners.return_value = [
@@ -736,7 +736,7 @@ class TestCLI:
assert "active" in result.output assert "active" in result.output
mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=False) mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=False)
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list_no_status(self, mock_manager_class: MagicMock) -> None: def test_list_no_status(self, mock_manager_class: MagicMock) -> None:
"""--no-status skips SSH checks and shows registry only.""" """--no-status skips SSH checks and shows registry only."""
mock_manager = MagicMock() mock_manager = MagicMock()
@@ -758,22 +758,22 @@ class TestCLI:
assert "n/a" in result.output assert "n/a" in result.output
mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=True) mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=True)
@patch("gitea_runner_manager.cli.click.prompt", return_value="secret") @patch("grm.cli.click.prompt", return_value="secret")
@patch("gitea_runner_manager.cli.sys.stdin") @patch("grm.cli.sys.stdin")
def test_collect_become_pass_tty(self, mock_stdin: MagicMock, mock_prompt: MagicMock) -> None: def test_collect_become_pass_tty(self, mock_stdin: MagicMock, mock_prompt: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass from grm.cli import _collect_become_pass
mock_stdin.isatty.return_value = True mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=True) == "secret" assert _collect_become_pass(ask_become_pass=True) == "secret"
@patch("gitea_runner_manager.cli.sys.stdin") @patch("grm.cli.sys.stdin")
def test_collect_become_pass_no_ask(self, mock_stdin: MagicMock) -> None: def test_collect_become_pass_no_ask(self, mock_stdin: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass from grm.cli import _collect_become_pass
mock_stdin.isatty.return_value = True mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=False) is None assert _collect_become_pass(ask_become_pass=False) is None
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list_with_piped_become_pass(self, mock_manager_class: MagicMock) -> None: def test_list_with_piped_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.list_runners.return_value = [] mock_manager.list_runners.return_value = []
@@ -784,7 +784,7 @@ class TestCLI:
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.list_runners.assert_called_once_with(become_pass="secret", no_status=False) mock_manager.list_runners.assert_called_once_with(become_pass="secret", no_status=False)
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list_with_become_password_file(self, mock_manager_class: MagicMock) -> None: def test_list_with_become_password_file(self, mock_manager_class: MagicMock) -> None:
"""--become-password-file reads password from file for grm list.""" """--become-password-file reads password from file for grm list."""
import os import os
@@ -806,7 +806,7 @@ class TestCLI:
finally: finally:
os.unlink(pw_file) os.unlink(pw_file)
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list_with_become_password_file_env(self, mock_manager_class: MagicMock) -> None: def test_list_with_become_password_file_env(self, mock_manager_class: MagicMock) -> None:
"""GRM_BECOME_PASSWORD_FILE env var works for grm list.""" """GRM_BECOME_PASSWORD_FILE env var works for grm list."""
import os import os
@@ -828,7 +828,7 @@ class TestCLI:
finally: finally:
os.unlink(pw_file) os.unlink(pw_file)
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list_empty(self, mock_manager_class: MagicMock) -> None: def test_list_empty(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.list_runners.return_value = [] mock_manager.list_runners.return_value = []
@@ -839,10 +839,10 @@ class TestCLI:
assert result.exit_code == 0 assert result.exit_code == 0
assert "No runners registered" in result.output assert "No runners registered" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_list_error(self, mock_manager_class: MagicMock) -> None: def test_list_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.list_runners.side_effect = AnsibleError("fail") mock_manager.list_runners.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -852,7 +852,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_health_all_healthy(self, mock_manager_class: MagicMock) -> None: def test_health_all_healthy(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.health.return_value = [ mock_manager.health.return_value = [
@@ -868,7 +868,7 @@ class TestCLI:
assert "r2" in result.output assert "r2" in result.output
assert "yes" in result.output assert "yes" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_health_with_unhealthy(self, mock_manager_class: MagicMock) -> None: def test_health_with_unhealthy(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.health.return_value = [ mock_manager.health.return_value = [
@@ -883,7 +883,7 @@ class TestCLI:
assert "unhealthy" in result.output.lower() assert "unhealthy" in result.output.lower()
assert "r2" in result.output assert "r2" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_health_single_runner(self, mock_manager_class: MagicMock) -> None: def test_health_single_runner(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.health.return_value = [ mock_manager.health.return_value = [
@@ -897,7 +897,7 @@ class TestCLI:
assert "r1" in result.output assert "r1" in result.output
mock_manager.health.assert_called_once() mock_manager.health.assert_called_once()
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_health_empty(self, mock_manager_class: MagicMock) -> None: def test_health_empty(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_manager.health.return_value = [] mock_manager.health.return_value = []
@@ -908,10 +908,10 @@ class TestCLI:
assert result.exit_code == 0 assert result.exit_code == 0
assert "No runners registered" in result.output assert "No runners registered" in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_health_error(self, mock_manager_class: MagicMock) -> None: def test_health_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock() mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
mock_manager.health.side_effect = AnsibleError("fail") mock_manager.health.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
@@ -921,8 +921,8 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty")) @patch("grm.cli.os.getlogin", side_effect=OSError("no tty"))
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_default_user_fallback_on_getlogin_error( def test_default_user_fallback_on_getlogin_error(
self, mock_manager_class: MagicMock, mock_getlogin: MagicMock self, mock_manager_class: MagicMock, mock_getlogin: MagicMock
) -> None: ) -> None:
@@ -937,8 +937,8 @@ class TestCLI:
call_kwargs = mock_manager.install.call_args.kwargs call_kwargs = mock_manager.install.call_args.kwargs
assert call_kwargs["user"] == "testuser" assert call_kwargs["user"] == "testuser"
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty")) @patch("grm.cli.os.getlogin", side_effect=OSError("no tty"))
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_default_user_fallback_to_root(self, mock_manager_class: MagicMock, mock_getlogin: MagicMock) -> None: def test_default_user_fallback_to_root(self, mock_manager_class: MagicMock, mock_getlogin: MagicMock) -> None:
"""os.getlogin() failure with no USER env falls back to 'root'.""" """os.getlogin() failure with no USER env falls back to 'root'."""
mock_manager = MagicMock() mock_manager = MagicMock()
@@ -951,7 +951,7 @@ class TestCLI:
call_kwargs = mock_manager.install.call_args.kwargs call_kwargs = mock_manager.install.call_args.kwargs
assert call_kwargs["user"] == "root" assert call_kwargs["user"] == "root"
@patch("gitea_runner_manager.cli.RunnerManager") @patch("grm.cli.RunnerManager")
def test_default_user_from_env(self, mock_manager_class: MagicMock) -> None: def test_default_user_from_env(self, mock_manager_class: MagicMock) -> None:
"""GITEA_RUNNER_USER env var takes priority over os.getlogin().""" """GITEA_RUNNER_USER env var takes priority over os.getlogin()."""
mock_manager = MagicMock() mock_manager = MagicMock()
@@ -970,20 +970,20 @@ class TestCLI:
def test_get_verbose_no_context(self) -> None: def test_get_verbose_no_context(self) -> None:
"""_get_verbose returns False when called outside Click context.""" """_get_verbose returns False when called outside Click context."""
from gitea_runner_manager.cli import _get_verbose from grm.cli import _get_verbose
assert _get_verbose() is False assert _get_verbose() is False
def test_get_become_password_file_no_context(self) -> None: def test_get_become_password_file_no_context(self) -> None:
"""_get_become_password_file returns None when no context and no env vars.""" """_get_become_password_file returns None when no context and no env vars."""
from gitea_runner_manager.cli import _get_become_password_file from grm.cli import _get_become_password_file
with patch.dict("os.environ", {}, clear=True): with patch.dict("os.environ", {}, clear=True):
assert _get_become_password_file() is None assert _get_become_password_file() is None
def test_get_become_password_file_from_ansible_env(self) -> None: def test_get_become_password_file_from_ansible_env(self) -> None:
"""_get_become_password_file falls back to ANSIBLE_BECOME_PASSWORD_FILE.""" """_get_become_password_file falls back to ANSIBLE_BECOME_PASSWORD_FILE."""
from gitea_runner_manager.cli import _get_become_password_file from grm.cli import _get_become_password_file
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/ansible.txt"}, clear=True): with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/ansible.txt"}, clear=True):
assert _get_become_password_file() == "/tmp/ansible.txt" assert _get_become_password_file() == "/tmp/ansible.txt"
@@ -994,7 +994,7 @@ class TestTriggerWorkflow:
def test_trigger_workflow_success(self) -> None: def test_trigger_workflow_success(self) -> None:
runner = CliRunner(env=_TEST_ENV) runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: with patch("grm.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.return_value = { mock_client.dispatch_workflow.return_value = {
@@ -1022,7 +1022,7 @@ class TestTriggerWorkflow:
def test_trigger_workflow_list(self) -> None: def test_trigger_workflow_list(self) -> None:
runner = CliRunner(env=_TEST_ENV) runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: with patch("grm.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
mock_client.list_workflows.return_value = [ mock_client.list_workflows.return_value = [
@@ -1037,7 +1037,7 @@ class TestTriggerWorkflow:
def test_trigger_workflow_list_empty(self) -> None: def test_trigger_workflow_list_empty(self) -> None:
runner = CliRunner(env=_TEST_ENV) runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: with patch("grm.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
mock_client.list_workflows.return_value = [] mock_client.list_workflows.return_value = []
@@ -1052,10 +1052,10 @@ class TestTriggerWorkflow:
assert "WORKFLOW_ID" in result.output assert "WORKFLOW_ID" in result.output
def test_trigger_workflow_api_error(self) -> None: def test_trigger_workflow_api_error(self) -> None:
from gitea_runner_manager.gitea_client import GiteaAPIError from grm.gitea_client import GiteaAPIError
runner = CliRunner(env=_TEST_ENV) runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: with patch("grm.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.side_effect = GiteaAPIError(404, "workflow not found") mock_client.dispatch_workflow.side_effect = GiteaAPIError(404, "workflow not found")
@@ -1065,7 +1065,7 @@ class TestTriggerWorkflow:
def test_trigger_workflow_custom_repo_and_ref(self) -> None: def test_trigger_workflow_custom_repo_and_ref(self) -> None:
runner = CliRunner(env=_TEST_ENV) runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls: with patch("grm.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.return_value = None mock_client.dispatch_workflow.return_value = None
+9 -9
View File
@@ -7,8 +7,8 @@ from unittest.mock import MagicMock, mock_open, patch
import pytest import pytest
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
from gitea_runner_manager.executor import AnsibleExecutor from grm.executor import AnsibleExecutor
def _mock_popen_process(returncode: int = 0) -> MagicMock: def _mock_popen_process(returncode: int = 0) -> MagicMock:
@@ -22,7 +22,7 @@ def _mock_popen_process(returncode: int = 0) -> MagicMock:
class TestAnsibleExecutorRun: class TestAnsibleExecutorRun:
def test_run_success(self, tmp_path: Path) -> None: def test_run_success(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.executor.say") as mock_say: with patch("grm.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path) executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()): with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0) mock_popen.return_value = _mock_popen_process(returncode=0)
@@ -33,7 +33,7 @@ class TestAnsibleExecutorRun:
assert any("Done" in msg for msg in messages) assert any("Done" in msg for msg in messages)
def test_run_failure(self, tmp_path: Path) -> None: def test_run_failure(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.executor.say") as mock_say: with patch("grm.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path) executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()): with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=1) mock_popen.return_value = _mock_popen_process(returncode=1)
@@ -45,7 +45,7 @@ class TestAnsibleExecutorRun:
assert "exit code 1" in error_calls[0].args[0] assert "exit code 1" in error_calls[0].args[0]
def test_run_default_description(self, tmp_path: Path) -> None: def test_run_default_description(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.executor.say") as mock_say: with patch("grm.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path) executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()): with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0) mock_popen.return_value = _mock_popen_process(returncode=0)
@@ -75,7 +75,7 @@ class TestAnsibleExecutorRun:
' "msg": "Runner \'127.0.0.1\' is installed and running."\n' ' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n" "}\n"
) )
with patch("gitea_runner_manager.executor.say") as mock_say: with patch("grm.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path) executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open(read_data=log_content)): with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open(read_data=log_content)):
mock_popen.return_value = _mock_popen_process(returncode=0) mock_popen.return_value = _mock_popen_process(returncode=0)
@@ -111,7 +111,7 @@ class TestAnsibleExecutorExtractStatus:
executor = AnsibleExecutor() executor = AnsibleExecutor()
log_file = tmp_path / "test.log" log_file = tmp_path / "test.log"
log_file.write_text("incomplete") log_file.write_text("incomplete")
with patch("gitea_runner_manager.executor.open", side_effect=OSError("read error")): with patch("grm.executor.open", side_effect=OSError("read error")):
status = executor._extract_status(log_file) status = executor._extract_status(log_file)
assert status is None assert status is None
@@ -138,7 +138,7 @@ class TestAnsibleExecutorTranslation:
def test_run_translated(self, tmp_path: Path) -> None: def test_run_translated(self, tmp_path: Path) -> None:
with ( with (
patch.dict(os.environ, {"GRM_LANG": "de"}), patch.dict(os.environ, {"GRM_LANG": "de"}),
patch("gitea_runner_manager.executor.say") as mock_say, patch("grm.executor.say") as mock_say,
): ):
executor = AnsibleExecutor(log_dir=tmp_path) executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()): with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
@@ -152,7 +152,7 @@ class TestAnsibleExecutorTranslation:
def test_unsupported_lang_fallback(self, tmp_path: Path) -> None: def test_unsupported_lang_fallback(self, tmp_path: Path) -> None:
with ( with (
patch.dict(os.environ, {"GRM_LANG": "xx"}), patch.dict(os.environ, {"GRM_LANG": "xx"}),
patch("gitea_runner_manager.executor.say") as mock_say, patch("grm.executor.say") as mock_say,
): ):
executor = AnsibleExecutor(log_dir=tmp_path) executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()): with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
+1 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from gitea_runner_manager.gitea_client import GiteaAPIError, GiteaWorkflowClient from grm.gitea_client import GiteaAPIError, GiteaWorkflowClient
class TestGiteaWorkflowClient: class TestGiteaWorkflowClient:
+1 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest import pytest
import gitea_runner_manager.i18n as i18n_module import grm.i18n as i18n_module
class TestI18n: class TestI18n:
+5 -5
View File
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest import pytest
from gitea_runner_manager.logging_config import get_logger from grm.logging_config import get_logger
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -25,7 +25,7 @@ def _cleanup_loggers() -> Generator[None, None, None]:
class TestGetLogger: class TestGetLogger:
def test_returns_logger(self, tmp_path: Path) -> None: def test_returns_logger(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path): with patch("grm.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_returns_logger") logger = get_logger("test_returns_logger")
assert isinstance(logger, logging.Logger) assert isinstance(logger, logging.Logger)
@@ -33,14 +33,14 @@ class TestGetLogger:
assert len(logger.handlers) == 1 # file only assert len(logger.handlers) == 1 # file only
def test_caches_same_instance(self, tmp_path: Path) -> None: def test_caches_same_instance(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path): with patch("grm.logging_config.Path.home", return_value=tmp_path):
logger1 = get_logger("test_caches_same") logger1 = get_logger("test_caches_same")
logger2 = get_logger("test_caches_same") logger2 = get_logger("test_caches_same")
assert logger1 is logger2 assert logger1 is logger2
def test_file_handler_writes(self, tmp_path: Path) -> None: def test_file_handler_writes(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path): with patch("grm.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_file_writes") logger = get_logger("test_file_writes")
logger.info("hello from test") logger.info("hello from test")
@@ -51,7 +51,7 @@ class TestGetLogger:
assert "INFO" in content assert "INFO" in content
def test_only_file_handler(self, tmp_path: Path) -> None: def test_only_file_handler(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path): with patch("grm.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_only_file") logger = get_logger("test_only_file")
assert len(logger.handlers) == 1 assert len(logger.handlers) == 1
+2 -2
View File
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest import pytest
from gitea_runner_manager.exceptions import GRMError from grm.exceptions import GRMError
from gitea_runner_manager.registry import RunnerRegistry from grm.registry import RunnerRegistry
class TestRunnerRegistry: class TestRunnerRegistry:
+8 -8
View File
@@ -6,8 +6,8 @@ from unittest.mock import patch
import pytest import pytest
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
from gitea_runner_manager.report import Step, StepTracker, track_steps from grm.report import Step, StepTracker, track_steps
class TestStep: class TestStep:
@@ -46,7 +46,7 @@ class TestStepTracker:
class TestTrackSteps: class TestTrackSteps:
def test_success(self) -> None: def test_success(self) -> None:
with patch("gitea_runner_manager.report.say") as mock_say: with patch("grm.report.say") as mock_say:
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin("step1") tracker.begin("step1")
tracker.done() tracker.done()
@@ -61,7 +61,7 @@ class TestTrackSteps:
assert any("completed" in msg for msg in messages) assert any("completed" in msg for msg in messages)
def test_failure_marks_step(self) -> None: def test_failure_marks_step(self) -> None:
with patch("gitea_runner_manager.report.say") as mock_say: with patch("grm.report.say") as mock_say:
with pytest.raises(AnsibleError, match="fail"): with pytest.raises(AnsibleError, match="fail"):
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin("step1") tracker.begin("step1")
@@ -78,7 +78,7 @@ class TestTrackSteps:
assert any("failed" in msg for msg in messages) assert any("failed" in msg for msg in messages)
def test_pending_shown_on_failure(self) -> None: def test_pending_shown_on_failure(self) -> None:
with patch("gitea_runner_manager.report.say") as mock_say: with patch("grm.report.say") as mock_say:
with pytest.raises(AnsibleError, match="fail"): with pytest.raises(AnsibleError, match="fail"):
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin("step1") tracker.begin("step1")
@@ -90,7 +90,7 @@ class TestTrackSteps:
def test_multiple_in_progress_marked_failed(self) -> None: def test_multiple_in_progress_marked_failed(self) -> None:
"""All in-progress steps should be marked as failed on exception.""" """All in-progress steps should be marked as failed on exception."""
with patch("gitea_runner_manager.report.say") as mock_say: with patch("grm.report.say") as mock_say:
with pytest.raises(AnsibleError, match="fail"): with pytest.raises(AnsibleError, match="fail"):
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin("step1") tracker.begin("step1")
@@ -103,7 +103,7 @@ class TestTrackSteps:
assert messages.count("") >= 2 or sum(1 for m in messages if "" in m) >= 2 assert messages.count("") >= 2 or sum(1 for m in messages if "" in m) >= 2
def test_empty_report(self) -> None: def test_empty_report(self) -> None:
with patch("gitea_runner_manager.report.say") as mock_say: with patch("grm.report.say") as mock_say:
with track_steps(): with track_steps():
pass pass
@@ -112,7 +112,7 @@ class TestTrackSteps:
def test_translated_report(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_translated_report(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GRM_LANG", "bg") monkeypatch.setenv("GRM_LANG", "bg")
with patch("gitea_runner_manager.report.say") as mock_say: with patch("grm.report.say") as mock_say:
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin("step1") tracker.begin("step1")
tracker.done() tracker.done()
+2 -2
View File
@@ -8,8 +8,8 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from gitea_runner_manager.exceptions import AnsibleError from grm.exceptions import AnsibleError
from gitea_runner_manager.runner_manager import RunnerManager from grm.runner_manager import RunnerManager
@contextmanager @contextmanager
+8 -8
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest import pytest
from gitea_runner_manager.ui import _console_level, say from grm.ui import _console_level, say
class TestConsoleLevel: class TestConsoleLevel:
@@ -26,15 +26,15 @@ class TestConsoleLevel:
class TestSay: class TestSay:
def test_echoes_to_console(self) -> None: def test_echoes_to_console(self) -> None:
with patch("gitea_runner_manager.ui.click.echo") as mock_echo: with patch("grm.ui.click.echo") as mock_echo:
say("hello") say("hello")
mock_echo.assert_called_once_with("hello", err=False) mock_echo.assert_called_once_with("hello", err=False)
def test_logs_at_info_level(self) -> None: def test_logs_at_info_level(self) -> None:
with ( with (
patch("gitea_runner_manager.ui.click.echo"), patch("grm.ui.click.echo"),
patch("gitea_runner_manager.ui.logging.getLogger") as mock_get_logger, patch("grm.ui.logging.getLogger") as mock_get_logger,
): ):
mock_logger = mock_get_logger.return_value mock_logger = mock_get_logger.return_value
say("hello") say("hello")
@@ -43,8 +43,8 @@ class TestSay:
def test_passes_level_and_err(self) -> None: def test_passes_level_and_err(self) -> None:
with ( with (
patch("gitea_runner_manager.ui.click.echo") as mock_echo, patch("grm.ui.click.echo") as mock_echo,
patch("gitea_runner_manager.ui.logging.getLogger") as mock_get_logger, patch("grm.ui.logging.getLogger") as mock_get_logger,
): ):
mock_logger = mock_get_logger.return_value mock_logger = mock_get_logger.return_value
say("error msg", level=logging.ERROR, err=True) say("error msg", level=logging.ERROR, err=True)
@@ -55,8 +55,8 @@ class TestSay:
def test_suppresses_console_below_level(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_suppresses_console_below_level(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GRM_LOG_LEVEL", "WARNING") monkeypatch.setenv("GRM_LOG_LEVEL", "WARNING")
with ( with (
patch("gitea_runner_manager.ui.click.echo") as mock_echo, patch("grm.ui.click.echo") as mock_echo,
patch("gitea_runner_manager.ui.logging.getLogger") as mock_get_logger, patch("grm.ui.logging.getLogger") as mock_get_logger,
): ):
mock_logger = mock_get_logger.return_value mock_logger = mock_get_logger.return_value
say("debug msg", level=logging.DEBUG) say("debug msg", level=logging.DEBUG)
Generated
+1 -1
View File
@@ -504,7 +504,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "gitea-runner-manager" name = "grm"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [