Compare commits

...
51 Commits
Author SHA1 Message Date
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
grm-ci-bot be17dc278c release: v0.14.1 [skip ci] 2026-07-01 23:14:00 +00:00
emil 58b8d5b5de GRM-130: chore: bump devx to 0.32.0, add devx.mak include fallback
Post-merge / detect-type (push) Successful in 1m12s
Post-merge / release (push) Successful in 1m4s
Post-merge / validate-commit-msg (push) Successful in 1m8s
Post-merge / badges (push) Successful in 1m27s
Post-merge / publish (push) Successful in 1m8s
Post-merge / sync-wiki (push) Successful in 2m38s
Post-merge / vikunja (push) Successful in 1m44s
Post-merge / configure-repo (push) Successful in 1m42s
2026-07-01 23:11:49 +00:00
gitea-actions-bot 48f23b554f chore: update badge URLs to commit 627be1f1 [skip ci] 2026-07-01 22:25:36 +00:00
emil d32bb40cbd GRM-130: refactor: align venv management to devx.mak targets
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 59s
Post-merge / publish (push) Has been skipped
Post-merge / validate-commit-msg (push) Successful in 1m32s
Post-merge / configure-repo (push) Successful in 1m34s
Post-merge / vikunja (push) Successful in 1m38s
Post-merge / badges (push) Successful in 1m46s
Post-merge / sync-wiki (push) Successful in 2m34s
2026-07-01 22:22:50 +00:00
gitea-actions-bot 34954aa396 chore: update badge URLs to commit 93c752fe [skip ci] 2026-07-01 20:57:47 +00:00
emil b9d728334f GRM-129: docs: add container-level fix verification and verified state modification rules
Post-merge / detect-type (push) Successful in 49s
Post-merge / release (push) Successful in 1m3s
Post-merge / publish (push) Has been skipped
Post-merge / validate-commit-msg (push) Successful in 1m9s
Post-merge / vikunja (push) Successful in 1m7s
Post-merge / badges (push) Successful in 1m17s
Post-merge / configure-repo (push) Successful in 56s
Post-merge / sync-wiki (push) Successful in 2m8s
2026-07-01 20:55:35 +00:00
gitea-actions-bot 02d7c02a19 chore: update badge URLs to commit 95cb0d84 [skip ci] 2026-07-01 14:20:51 +00:00
grm-ci-bot f861d14f32 release: v0.14.0 [skip ci] 2026-07-01 14:20:20 +00:00
emil 4359dbdc26 GRM-128: feat: bump devx to v0.30.0
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m9s
Post-merge / validate-commit-msg (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m32s
Post-merge / publish (push) Successful in 1m15s
Post-merge / vikunja (push) Successful in 1m22s
Post-merge / configure-repo (push) Successful in 1m25s
Post-merge / sync-wiki (push) Successful in 2m47s
2026-07-01 14:18:24 +00:00
gitea-actions-bot fc494f5cc0 chore: update badge URLs to commit 647c885c [skip ci] 2026-07-01 10:32:00 +00:00
grm-ci-bot 461ec207ad release: v0.13.0 [skip ci] 2026-07-01 10:30:21 +00:00
emil dca82753b2 GRM-127: feat: bump devx to v0.29.1, upgrade molecule, ubuntu 26.04
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m30s
Post-merge / validate-commit-msg (push) Successful in 1m48s
Post-merge / vikunja (push) Successful in 2m1s
Post-merge / configure-repo (push) Successful in 1m18s
Post-merge / publish (push) Successful in 1m21s
Post-merge / badges (push) Successful in 3m4s
Post-merge / sync-wiki (push) Successful in 3m34s
2026-07-01 10:27:58 +00:00
gitea-actions-bot 3b952b09b5 chore: update badge URLs to commit b8aa4072 [skip ci] 2026-07-01 01:07:14 +00:00
emil 833792d0ad GRM-125: ci: bump devx to v0.28.0, add pre-merge-check, agent docs
Post-merge / detect-type (push) Successful in 55s
Post-merge / release (push) Successful in 1m16s
Post-merge / validate-commit-msg (push) Successful in 1m18s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m31s
Post-merge / vikunja (push) Successful in 1m20s
Post-merge / configure-repo (push) Successful in 1m18s
Post-merge / sync-wiki (push) Successful in 2m13s
2026-07-01 01:04:26 +00:00
gitea-actions-bot d8a90eaea1 chore: update badge URLs to commit 6a2a9bb9 [skip ci] 2026-07-01 00:21:16 +00:00
emil 358620401d GRM-124: docs: fix outdated references and document health/restart/trigger-workflow commands
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 59s
Post-merge / validate-commit-msg (push) Successful in 1m20s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m43s
Post-merge / sync-wiki (push) Successful in 2m5s
Post-merge / vikunja (push) Successful in 1m9s
Post-merge / configure-repo (push) Successful in 1m8s
2026-07-01 00:18:44 +00:00
gitea-actions-bot 32f0ad5cb3 chore: update badge URLs to commit 2db35931 [skip ci] 2026-06-30 23:31:22 +00:00
grm-ci-bot 4e9d033a40 release: v0.12.5 [skip ci] 2026-06-30 23:30:56 +00:00
emil 63ef5cdbcf GRM-123: fix: cast disk threshold to string in template-content verify assertion
Post-merge / detect-type (push) Successful in 49s
Post-merge / validate-commit-msg (push) Successful in 1m6s
Post-merge / vikunja (push) Successful in 1m11s
Post-merge / release (push) Successful in 1m21s
Post-merge / badges (push) Successful in 1m37s
Post-merge / configure-repo (push) Successful in 1m20s
Post-merge / publish (push) Successful in 57s
Post-merge / sync-wiki (push) Successful in 2m35s
2026-06-30 23:28:54 +00:00
gitea-actions-bot 8fbe2d3f51 chore: update badge URLs to commit ec6dc73a [skip ci] 2026-06-29 12:04:56 +00:00
emil a9178714af GRM-122: fix: right-size molecule-tests matrix to [1-6]
Post-merge / detect-type (push) Successful in 1m2s
Post-merge / release (push) Successful in 1m0s
Post-merge / validate-commit-msg (push) Successful in 1m3s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m11s
Post-merge / vikunja (push) Successful in 1m14s
Post-merge / sync-wiki (push) Successful in 1m34s
Post-merge / configure-repo (push) Successful in 1m15s
2026-06-29 12:02:41 +00:00
gitea-actions-bot 6ffcc38181 chore: update badge URLs to commit f47c3dff [skip ci] 2026-06-29 11:56:25 +00:00
grm-ci-bot e5b0e17ec3 release: v0.12.4 [skip ci] 2026-06-29 11:55:20 +00:00
emil dc5e1431b5 GRM-121: fix: use hardcoded matrix array for Gitea 1.26 compatibility
Post-merge / detect-type (push) Successful in 1m12s
Post-merge / release (push) Successful in 48s
Post-merge / validate-commit-msg (push) Successful in 1m41s
Post-merge / vikunja (push) Successful in 1m38s
Post-merge / configure-repo (push) Successful in 1m37s
Post-merge / badges (push) Successful in 1m48s
Post-merge / publish (push) Successful in 1m11s
Post-merge / sync-wiki (push) Successful in 2m10s
2026-06-29 11:53:22 +00:00
gitea-actions-bot dfa8d77bfa chore: update badge URLs to commit 26565ddc [skip ci] 2026-06-29 11:39:57 +00:00
emil a178b1b5d2 GRM-120: chore: pin devx to 0.27.2 for dynamic runner matrix support
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m13s
Post-merge / validate-commit-msg (push) Successful in 1m44s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m53s
Post-merge / vikunja (push) Successful in 1m52s
Post-merge / sync-wiki (push) Successful in 2m10s
Post-merge / configure-repo (push) Successful in 1m34s
2026-06-29 11:37:03 +00:00
gitea-actions-bot 99529a57af chore: update badge URLs to commit f731a4a7 [skip ci] 2026-06-29 10:41:23 +00:00
grm-ci-bot 0eb033419f release: v0.12.3 [skip ci] 2026-06-29 10:40:45 +00:00
emil df4b7f2a19 GRM-118: fix: improve runner service stability and deregistration
Post-merge / detect-type (push) Successful in 1m16s
Post-merge / release (push) Successful in 1m12s
Post-merge / validate-commit-msg (push) Successful in 1m27s
Post-merge / configure-repo (push) Successful in 1m27s
Post-merge / vikunja (push) Successful in 1m31s
Post-merge / badges (push) Successful in 1m33s
Post-merge / sync-wiki (push) Successful in 1m43s
Post-merge / publish (push) Successful in 46s
2026-06-29 10:38:32 +00:00
gitea-actions-bot 312a706d39 chore: update badge URLs to commit 695921d6 [skip ci] 2026-06-28 17:09:22 +00:00
emil ea2f0cc600 GRM-117: fix: fix wiki link URLs, heading hierarchy, quote pip install vars
Post-merge / detect-type (push) Successful in 51s
Post-merge / release (push) Successful in 54s
Post-merge / validate-commit-msg (push) Successful in 1m5s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m17s
Post-merge / vikunja (push) Successful in 1m26s
Post-merge / configure-repo (push) Successful in 1m12s
Post-merge / sync-wiki (push) Successful in 1m34s
2026-06-28 17:07:11 +00:00
gitea-actions-bot bd8e13ee66 chore: update badge URLs to commit 622165d8 [skip ci] 2026-06-28 15:06:01 +00:00
grm-ci-bot 41fc36ff4a release: v0.12.2 [skip ci] 2026-06-28 15:05:36 +00:00
emil e585543e9d GRM-116: fix: bump devx to 0.26.3 (latest with pinned deps)
Post-merge / detect-type (push) Successful in 56s
Post-merge / release (push) Successful in 1m12s
Post-merge / validate-commit-msg (push) Successful in 1m15s
Post-merge / vikunja (push) Successful in 1m20s
Post-merge / badges (push) Successful in 1m34s
Post-merge / sync-wiki (push) Successful in 2m0s
Post-merge / configure-repo (push) Successful in 1m26s
Post-merge / publish (push) Successful in 1m0s
2026-06-28 15:03:33 +00:00
gitea-actions-bot c62c35f5b6 chore: update badge URLs to commit ab26a799 [skip ci] 2026-06-28 14:57:29 +00:00
grm-ci-bot 4b900ce673 release: v0.12.1 [skip ci] 2026-06-28 14:56:55 +00:00
emil cae66e0743 GRM-114: fix: add approval step to auto-merge workflow using REVIEW_GITEA_TOKEN
Post-merge / detect-type (push) Successful in 1m6s
Post-merge / release (push) Successful in 1m13s
Post-merge / validate-commit-msg (push) Successful in 1m17s
Post-merge / vikunja (push) Successful in 1m18s
Post-merge / badges (push) Successful in 1m25s
Post-merge / configure-repo (push) Successful in 1m14s
Post-merge / sync-wiki (push) Successful in 1m56s
Post-merge / publish (push) Successful in 1m4s
2026-06-28 14:54:53 +00:00
gitea-actions-bot 0b3a76c550 chore: update badge URLs to commit b73d161e [skip ci] 2026-06-28 13:15:36 +00:00
grm-ci-bot a4d5ba6b70 release: v0.12.0 [skip ci] 2026-06-28 13:15:08 +00:00
emil d9ce4e240f GRM-113: feat: upgrade all dependencies, add trigger-workflow command
Post-merge / vikunja (push) Successful in 1m13s
Post-merge / configure-repo (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m33s
Post-merge / detect-type (push) Successful in 55s
Post-merge / release (push) Successful in 1m19s
Post-merge / validate-commit-msg (push) Successful in 1m19s
Post-merge / publish (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m54s
2026-06-28 13:13:06 +00:00
gitea-actions-bot c1f68f115a chore: update badge URLs to commit dfe10dfc [skip ci] 2026-06-28 11:41:38 +00:00
grm-ci-bot fdf1293c85 release: v0.11.1 [skip ci] 2026-06-28 11:41:20 +00:00
emil 01b3f594f7 GRM-112: fix: Makefile HOST/NAME requirement errors, add restart and list targets
Post-merge / detect-type (push) Successful in 48s
Post-merge / release (push) Successful in 1m0s
Post-merge / validate-commit-msg (push) Successful in 1m3s
Post-merge / vikunja (push) Successful in 1m3s
Post-merge / badges (push) Successful in 1m11s
Post-merge / sync-wiki (push) Successful in 1m35s
Post-merge / configure-repo (push) Successful in 58s
Post-merge / publish (push) Successful in 57s
2026-06-28 11:39:37 +00:00
gitea-actions-bot 9ffa7a3671 chore: update badge URLs to commit cc0e4b3f [skip ci] 2026-06-28 11:23:32 +00:00
63 changed files with 2602 additions and 1297 deletions
+185
View File
@@ -0,0 +1,185 @@
---
name: ci-investigator
description: Investigates CI failures in the grm repo by fetching job logs via Gitea MCP, identifying root cause across quality/molecule-tests/release/publish/wiki-sync jobs, and validating fixes locally.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
permissions:
allow:
- Exec(git log *)
- Exec(git diff *)
- Exec(git show *)
- Exec(curl *)
- Exec(docker *)
- Exec(python3 *)
- Exec(make *)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(head *)
- Exec(tail *)
- Exec(wc *)
- mcp__gitea__*
- mcp__vikunja__*
---
You are a CI failure investigator for the grm repo.
## Working Directory & Virtual Environment
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## CI Job Dependency Graph
**ci.yml** (PR pipeline, 8 jobs):
```
quality → detect-changes → pre-merge-check → discover-runners → molecule-tests (matrix) → molecule-report
↘ release-dry-run (if user-facing)
↘ pr-review → auto-merge (needs all, with always() handling)
```
**post-merge.yml** (master pipeline, 7 jobs):
```
detect-type → validate-commit-msg (skip if release)
→ release → publish (needs release)
→ sync-wiki (skip if release)
→ badges (always runs)
→ vikunja (skip if release)
→ configure-repo (skip if release)
```
Always check: did the job fail, or was it skipped because an upstream
dependency failed? Skipped jobs are not the root cause.
## Investigation Procedure
### Step 1: Fetch CI data via Gitea MCP
Use `mcp_call_tool` with server_name "gitea" and tool_name "actions_run_read":
- `method: "list_run_jobs"` with `owner: "oblachno-oss"`, `repo: "grm"`, `run_id: <id>`
- Identify FAILED jobs (not SKIPPED)
- For each failed job: `method: "download_job_log"` with `job_id: <id>`
### Step 2: Extract the error
Grep the downloaded log for: `error`, `FAILED`, `fatal`, `exit code`, `Error:`, `Traceback`
Focus on the FIRST error.
### Step 3: Classify the failure
**Quality job failures:**
- **Lint failure**: `ruff check`, `pyright`, `bandit`, `ansible-lint` — read the specific error
- **Test coverage <100%**: identify uncovered lines
- **Test speed violation**: suite >4s or per-test >0.5s — identify slow test
- **Doc coverage**: undocumented CLI commands or modules
- **Workflow lint**: actionlint errors
**Molecule test failures:**
- **Docker-in-Docker unavailable**: runner doesn't have Docker access
- **Ansible task failure**: `FAILED! =>` — identify the task and role
- **Platform-specific failure**: one OS fails (e.g. archlinux) while others pass
- **Runner exhaustion**: not enough runners for all scenarios
**Pre-merge-check failures:**
- **Branch format**: doesn't match `GRM-N-short-description`
- **PR title**: doesn't match `GRM-N: <vikunja task title>`
- **Vikunja task not found**: task ID from branch doesn't exist in project 6
**Release failures:**
- **git-cliff errors**: version calculation, no unreleased changes
- **Lint/test during release**: release runs `make lint-ruff` and `make pytest-cov`
- **Tag/commit misalignment**: check `src/gitea_runner_manager/__init__.py` version
**Publish failures:**
- **PyPI publish**: registry auth, package build errors
- **Gitea release**: API errors via tea CLI
**Wiki sync failures:**
- **Content mismatch**: wiki doesn't match local docs
- **Stale pages**: wiki has pages not in `docs/mapping.json`
### Step 4: Verify the fix locally
```bash
make pytest-cov # 100% coverage
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
make check-test-speed # 4s suite, 0.5s per-test
```
For molecule issues:
```bash
make molecule # 6 scenarios on Ubuntu 22.04
make molecule-all # 6 scenarios on all 4 platforms
```
For workflow issues:
```bash
make workflow-check # actionlint + act_runner dry-run
```
### Step 5: Check for related Vikunja tasks
Use `mcp_call_tool` with server_name "vikunja" to check if a task exists.
CI auto-creates Gitea issues via `notify_failure`.
### Step 6: Report
1. **Root cause**: the specific error and why it occurred
2. **Evidence**: log excerpts, local verification results
3. **Affected files**: file paths and line numbers
4. **Suggested fix**: specific code change with rationale
5. **Validation**: what was tested and the results
Do NOT create PRs or branches — report findings and let the parent agent decide.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+155
View File
@@ -0,0 +1,155 @@
---
name: dep-upgrader
description: Researches and applies Python/Ansible dependency upgrades in pyproject.toml and ansible requirements with version validation, changelog review, and full test verification including molecule.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
permissions:
allow:
- mcp__gitea__*
- Exec(make pytest-cov)
- Exec(make lint-all)
- Exec(make molecule)
- Exec(python3 -m devx.tools.check_test_speed *)
- Exec(python3 -m devx.tools.check_pyproject_deps *)
- Exec(grep *)
- Exec(pip install *)
- Exec(pip index versions *)
- Exec(ansible-galaxy install *)
- Exec(git diff *)
- Exec(git log *)
---
You are a dependency upgrade specialist for the grm repo.
## Working Directory & Virtual Environment
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Dependency Reference Locations
- **Python deps**: `pyproject.toml``[project] dependencies` and `[project.optional-dependencies]`
- **Ansible deps**: `ansible/requirements.yml` — galaxy collections and roles
- **Dep documentation**: Each pyproject.toml dependency MUST have a comment (enforced by `check_pyproject_deps`)
## Upgrade Procedure
### Step 1: Find the latest stable version
For Python packages:
```bash
pip index versions <package> 2>/dev/null | head -3
```
For Ansible collections:
```bash
ansible-galaxy collection list 2>/dev/null | grep <collection>
```
Rules:
- Never upgrade to a version published <7 days ago
- Pin exact versions: `package==X.Y.Z`
- For Ansible collections: `community.docker:==3.10.2`
### Step 2: Review breaking changes
Read the changelog/release notes. Look for:
- Breaking API changes
- Deprecated features
- Minimum Python/Ansible version changes
- New required dependencies
### Step 3: Apply the upgrade
**Python deps** — edit `pyproject.toml`:
Each dependency line MUST have a trailing comment:
```toml
"ruff==0.12.0", # Python linter and formatter
```
**Ansible collections** — edit `ansible/requirements.yml`:
```yaml
collections:
- name: community.docker
version: "==3.10.2"
```
### Step 4: Install and verify
```bash
pip install -e .[dev] # reinstall with new deps
ansible-galaxy install -r ansible/requirements.yml # update collections
make pytest-cov # 100% coverage
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
.venv/bin/python -m devx.tools.check_pyproject_deps
.venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
```
If the dependency affects Ansible behavior, also run molecule:
```bash
make molecule # 6 scenarios on Ubuntu 22.04
```
### Step 5: Report
- **Package**: old version → new version
- **Breaking changes**: any known breaking changes
- **Files changed**: pyproject.toml, requirements.yml, source files (if API changed)
- **Test results**: pytest-cov, lint-all, check-pyproject-deps, test-speed, molecule (if run)
- **Verification**: version confirmation
Do NOT commit or push — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+138
View File
@@ -0,0 +1,138 @@
---
name: doc-syncer
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
allowed-tools:
- read
- grep
- glob
- exec
- edit
- mcp_call_tool
- mcp_list_tools
permissions:
allow:
- Exec(python3 -m devx.ci.doc_coverage *)
- Exec(python3 -m devx.ci.lint_docs *)
- Exec(python3 -m devx.ci.sync_wiki *)
- Exec(make check-docs)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(git diff *)
- mcp__gitea__*
---
You are a documentation sync specialist for the grm repo.
## Working Directory & Virtual Environment
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Documentation Structure
```
docs/
├── index.md # Wiki homepage
├── mapping.json # File-to-wiki-page title mapping (13 entries)
├── user/ # User documentation
│ ├── getting-started.md
│ ├── installation.md
│ ├── cli-commands.md
│ ├── troubleshooting.md
│ └── faq.md
└── tech/ # Technical documentation
├── architecture.md
├── development-setup.md
├── ci-cd-workflow.md
├── testing-strategy.md
├── decision-log.md
└── contributing.md
```
## Procedure
### Step 1: Check documentation coverage
```bash
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
```
Fix undocumented CLI commands, modules, or CI scripts by adding entries
to the appropriate docs file.
### Step 2: Lint documentation structure
```bash
.venv/bin/python -m devx.ci.lint_docs --root .
```
Fix: broken internal links, heading hierarchy skips, TODO/FIXME markers,
trailing whitespace.
### Step 3: Check for stale references
```bash
make check-docs
```
Update any references to files that were renamed or deleted.
### Step 4: Verify wiki sync (if investigating a sync failure)
```bash
.venv/bin/python -m devx.ci.sync_wiki --repo oblachno-oss/grm --strict
```
Check `docs/mapping.json` — every docs file should have a mapping entry.
If adding a new docs file, add it to mapping.json with a wiki-compatible
title (hyphens for spaces, no special characters).
### Step 5: Report
- **Coverage gaps**: undocumented items found and fixed
- **Lint issues**: structural problems found and fixed
- **Stale references**: outdated references updated
- **Wiki sync**: result of sync verification (if run)
- **Files changed**: all docs files modified
Do NOT commit — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+158
View File
@@ -0,0 +1,158 @@
---
name: molecule-runner
description: Runs molecule test scenarios for the gitea-runner Ansible role and reports pass/fail with logs. Knows all 7 scenarios, 4 platforms, Docker prerequisites, and dynamic runner distribution.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
permissions:
allow:
- mcp__gitea__*
- Exec(make molecule *)
- Exec(molecule *)
- Exec(docker *)
- Exec(ls *)
- Exec(cat *)
- Exec(grep *)
- Exec(head *)
- Exec(tail *)
---
You are a molecule test runner for the grm repo.
## Working Directory & Virtual Environment
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Available Scenarios (7 total)
| Scenario | Purpose | Makefile target |
|----------|---------|-----------------|
| default | Basic runner installation | `make molecule` (included) |
| multi-instance | 2 runners on same host | `make molecule` (included) |
| lifecycle | stop/disable/enable/start | `make molecule` (included) |
| template-content | Rendered template verification | `make molecule` (included) |
| deregister | Runner cleanup | `make molecule` (included) |
| update | Binary update | `make molecule` (included) |
| remove | Full removal (destroys container) | CI only (not in `make molecule`) |
**Platforms** (4): ubuntu-2204, ubuntu-2404, debian-12, archlinux
Platform list defined in `devx.molecule.platforms` (single source of truth).
**Note**: `make molecule` runs 6 scenarios (excludes `remove`).
`make molecule-all` runs 6 scenarios on all 4 platforms.
CI discovers all 7 scenarios via `devx.molecule.distribute_molecule`.
## Molecule Weights (for LPT distribution)
Configured in `pyproject.toml` `[tool.devx.molecule.weights]`:
```
multi-instance = 8, lifecycle = 6, update = 5, default = 4,
deregister = 3, remove = 3, template-content = 2
```
## Docker Prerequisites
```bash
docker info > /dev/null 2>&1 && echo "Docker ready" || echo "Docker not available"
```
If Docker is not running, report immediately — do not attempt to start it.
## Running Tests
When given a scenario name or "all":
1. Verify Docker is running
2. Run the appropriate make target
3. Capture full output (do not truncate)
4. Parse results
For a single scenario:
```bash
molecule test -s <scenario>
```
For all scenarios on one platform:
```bash
make molecule
```
For all scenarios on all platforms:
```bash
make molecule-all
```
## Known Issues
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user`
calls — this is expected (systemd module doesn't support user services) and
skipped in `.ansible-lint`
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
## Reporting
Report:
- **PASSED**: scenario name, platform, duration
- **FAILED**: scenario name, platform, the failing Ansible task, error message, file:line
- **SKIPPED**: if Docker was unavailable
For failures, extract:
- The Ansible task: `TASK [gitea-runner : task_name]` followed by `FAILED!`
- The error detail: the `msg` field in the JSON output
- The molecule verify step: look for `VERIFY` section
- Platform-specific failures: note if only one OS failed
Do NOT attempt to fix failures — report them with enough detail for the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+154
View File
@@ -0,0 +1,154 @@
---
name: workflow-validator
description: Validates Gitea Actions workflow YAML files for the grm repo using actionlint and act_runner dry-run. Fixes syntax errors, job dependency issues, and molecule distribution matrix problems.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
permissions:
allow:
- mcp__gitea__*
- Exec(make workflow-lint)
- Exec(make workflow-dryrun)
- Exec(make workflow-check)
- Exec(make install-tools)
- Exec(actionlint *)
- Exec(act_runner *)
- Exec(cat *)
- Exec(grep *)
- Exec(git diff *)
---
You are a Gitea Actions workflow validator for the grm repo.
## Working Directory & Virtual Environment
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Key Files
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, pre-merge-check, discover-runners, molecule-tests, molecule-report, release-dry-run, pr-review, auto-merge)
- `.gitea/workflows/post-merge.yml` — master pipeline (detect-type, validate-commit-msg, release, publish, sync-wiki, badges, vikunja, configure-repo)
- `.gitea/actionlint.yaml` — actionlint config (registers custom `docker` runner label)
## Validation Procedure
### Step 1: Install tools (if not present)
```bash
make install-tools # installs actionlint, act_runner to ~/.local/bin
```
### Step 2: Static lint with actionlint
```bash
make workflow-lint
```
Fix any: syntax errors, invalid expressions, unknown keys, shellcheck issues,
undefined variables, unknown actions, job dependency issues.
### Step 3: Dry-run with act_runner
```bash
make workflow-dryrun
```
Fix any: image not found, circular dependencies, step ordering issues,
matrix expansion problems.
### Step 4: Full check
```bash
make workflow-check
```
## GRM-Specific Workflow Concerns
**Molecule test distribution:**
The `molecule-tests` job uses a matrix `[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]`
with `max-parallel: 3`. Runners beyond the discovered count skip via
`--skip-if-excess`. The `discover-runners` job queries the Gitea API
for available runners.
If the matrix is too small, some scenarios won't run. If too large,
excess runners skip (no harm). The default 10 slots should be enough.
**Path filtering:**
Molecule tests only run when `ansible/` or `.ansible-lint` files change.
The `detect-changes` job sets `ansible-changed` output. If this is false,
molecule-tests is skipped — this is expected behavior.
**auto-merge and always():**
```yaml
auto-merge:
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pre-merge-check.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
**Gitea Actions limitations (1.26.x):**
- No `fromJSON()` in matrix context
- `concurrency` blocks can cause stuck jobs
- `GITHUB_OUTPUT` for step outputs
## Report
- **actionlint results**: pass/fail per workflow file, specific errors
- **dry-run results**: pass/fail per workflow, job dependency issues
- **Files changed**: if any workflow YAML was modified
- **Verification**: re-run results after fixes
Do NOT commit — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+38
View File
@@ -0,0 +1,38 @@
# devx-workflow
Quick reference for devx tools when working on this repo.
## PR Workflow (use these, not raw git/tea/MCP)
| Task | Command |
|------|---------|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
| Create PR | `make create-pr` |
| Push + create PR | `make push-with-pr` |
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
| Rebase current branch | `make rebase` |
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
## Auto-merge Behavior
When the `ready-to-merge` label is added and all CI checks pass:
1. Auto-merge validates PR title format (`GRM-N: <vikunja task title>`)
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
3. The rebase triggers a new CI run; the next auto-merge attempt merges
4. No manual rebase needed unless the API rebase fails
## Pre-merge Check
CI runs a `pre-merge-check` job early (after quality + detect-changes)
that validates branch format, PR title, and Vikunja task match.
This fails fast before expensive molecule tests run.
## Key Rules
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
- Branch naming: `GRM-N-short-description` (N = Vikunja task ID)
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
- PR title: `GRM-N: <vikunja task title>` (auto-derived by `make create-pr`)
@@ -0,0 +1,92 @@
# testing-and-debugging
Make targets for testing, debugging, and CI investigation. **Use these
instead of raw `pytest`, `ruff`, or `molecule` commands.**
## Why Make Targets
Make targets encapsulate the correct venv activation, PYTHONPATH, env
vars, and flags. Running raw commands bypasses venv activation and
produces false failures (missing dependencies, wrong Python version).
## Unit Tests
| Task | Command | Notes |
|------|---------|-------|
| Run all unit tests | `make test-unit` | Fast, no coverage |
| Run with coverage | `make pytest-cov` | **Required before push** — enforces 100% |
| Run single test | `make pytest-cov TEST=tests/test_foo.py::test_bar` | |
## Linting
| Task | Command | Notes |
|------|---------|-------|
| Full lint | `make lint-all` | ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
| Ruff only | `make lint-ruff` | |
| Type check | `make typecheck` | pyright |
| Bandit | `make lint-bandit` | Security linter |
| Workflow lint | `make workflow-check` | actionlint + act_runner dry-run |
## Molecule Tests
| Task | Command | Notes |
|------|---------|-------|
| All scenarios | `make molecule` | All 6 scenarios on Ubuntu 22.04 |
| All platforms | `make molecule-all` | All 6 scenarios on all 4 OSes |
| Parallel | `make molecule-all-parallel` | MOLECULE_JOBS=4 |
## Pre-Push Verification
**Before pushing any branch:**
```bash
make pre-push
```
This runs `lint-all` + `pytest-cov`. The pre-push git hook only
validates the Vikunja task exists — it does NOT run tests. You must
run `make pre-push` manually.
## CI Failure Investigation
When investigating a CI failure:
1. **Fetch logs via MCP** — use `mcp_call_tool` with gitea server,
`actions_run_read` method, `download_job_log` tool
2. **Reproduce locally** — use `make pytest-cov` or `make lint-ci`
depending on which CI job failed
3. **Never run raw pytest** — always use the make target
## Virtual Environment
All commands run inside `.venv`. `make` targets handle activation
automatically. For raw commands (rare), activate first:
```bash
source activate.sh # bash/zsh
source activate.fish # fish
source activate.zsh # zsh
```
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.
+7
View File
@@ -24,6 +24,9 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# Default SSH user for remote hosts (optional, overrides --user)
# GITEA_RUNNER_USER=ubuntu
# Repository for grm trigger-workflow (optional, default: oblachno-oss/grm)
# GRM_REPO=oblachno-oss/grm
# Default SSH private key path (optional, overrides --key)
# GITEA_RUNNER_KEY=~/.ssh/id_ed25519
@@ -47,6 +50,10 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL
CI_GITEA_USERNAME=emil
# Vikunja API token (required for `make create-task` dev workflow)
# Generate at: Vikunja → Settings → API Tokens
# VIKUNJA_TOKEN=your-vikunja-api-token
# devx configuration (GRM-specific overrides)
# Task prefix for Vikunja task IDs
DEVX_TASK_PREFIX=GRM
+73 -17
View File
@@ -20,36 +20,42 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=lint
run: make setup-image EXTRAS=ci,lint
- name: Lint all
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Documentation lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.lint_docs --root .
- name: Translation completeness check
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations --translations src/gitea_runner_manager/translations.json
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Dependency security scan
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
# Best-effort: only runs if act_runner is installed
if command -v act_runner >/dev/null 2>&1; then
@@ -79,7 +85,7 @@ jobs:
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
@@ -105,12 +111,43 @@ jobs:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
pre-merge-check:
needs: [quality, detect-changes]
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-image EXTRAS=ci
- name: Validate auto-merge preconditions
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
PYTHONPATH: ${{ env.PYTHONPATH }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_auto_merge_ready \
--branch "$HEAD_REF" \
--pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER"
discover-runners:
needs: [detect-changes]
if: needs.detect-changes.outputs.ansible-changed == 'true'
@@ -134,7 +171,7 @@ jobs:
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.discover_runners \
--owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \
@@ -147,8 +184,10 @@ jobs:
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
strategy:
fail-fast: true
max-parallel: 3
matrix:
runner-index: [1, 2, 3]
runner-index: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -158,7 +197,7 @@ jobs:
run: make setup-image EXTRAS=ci,molecule
- name: Install Ansible collections
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.setup --skip-install --no-pre-commit --no-tea-login
- name: Discover assigned test pairs
env:
@@ -166,7 +205,7 @@ jobs:
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \
@@ -174,7 +213,7 @@ jobs:
- name: Run molecule tests
if: env.SKIP != 'true'
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
if ! python3 -c "import docker; docker.from_env().ping()" 2>/dev/null; then
echo "Docker not available in CI container — skipping molecule tests"
@@ -188,6 +227,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
RUN_ID: ${{ github.run_id }}
ANSIBLE_INJECT_INVOCATION: "1"
JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }}
@@ -215,7 +255,7 @@ jobs:
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
@@ -225,11 +265,12 @@ jobs:
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it evaluates even when molecule-tests is skipped
# (Gitea Actions skips dependent jobs of skipped jobs by default).
needs: [quality, detect-changes, pr-review, molecule-tests, release-dry-run]
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests, release-dry-run]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pre-merge-check.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
@@ -249,6 +290,21 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Post approval review
env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, molecule, pr-review, pre-merge-check)."
- name: Squash merge with task ID
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
@@ -261,7 +317,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+9 -8
View File
@@ -29,6 +29,7 @@ name: Post-merge
on:
push:
branches: [master]
workflow_dispatch:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
@@ -55,7 +56,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
@@ -78,7 +79,7 @@ jobs:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -113,7 +114,7 @@ jobs:
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
@@ -151,7 +152,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish \
"${{ needs.release.outputs.tag }}" \
@@ -190,7 +191,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
@@ -230,7 +231,7 @@ jobs:
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
@@ -267,7 +268,7 @@ jobs:
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
@@ -303,7 +304,7 @@ jobs:
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / quality (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
+31 -7
View File
@@ -57,6 +57,37 @@ repos:
pass_filenames: false
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/gitea_runner_manager/translations.json
language: system
files: ^src/gitea_runner_manager/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
name: pytest with 100% coverage
entry: make pytest-cov
@@ -64,10 +95,3 @@ repos:
types: [python]
pass_filenames: false
stages: [pre-push]
- id: commit-msg
name: validate commit message
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
language: system
stages: [commit-msg]
pass_filenames: true
+167 -15
View File
@@ -1,5 +1,19 @@
# AGENTS.md — Project Conventions for GRM
## Virtual Environment
All Python tools, tests, and scripts run inside a standard `.venv` directory.
Activate it before running any non-`make` command:
```bash
source activate.sh # bash/zsh
source activate.fish # fish
source activate.zsh # zsh
```
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
venv activation automatically — always prefer `make <target>` over raw commands.
## Build & Test Commands
```bash
@@ -98,8 +112,7 @@ docs: update README
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
**Review checklist:** Every PR is reviewed against
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 13 categories covering
**Review checklist:** Every PR is reviewed against 13 categories covering
architecture, code quality, security, i18n, testing, performance,
UX, documentation, workflow compliance, maintainability, resource
management, backwards compatibility, and logging.
@@ -120,11 +133,11 @@ the **[auto]** items in the checklist:
- Commit conventions (conventional commit format on PR commits)
The automated review posts inline comments on specific lines and
includes a link to the full checklist. The agent **must** address all
includes a summary of the checklist categories. The agent **must** address all
`REQUEST_CHANGES` issues before proceeding.
**Manual review (agent):** After the automated review passes, the agent
must go through **every category** in `REVIEW_CHECKLIST.md` and verify
must go through **every category** listed above and verify
the **[manual]** items by reviewing the full diff
(`git diff master...HEAD`).
@@ -145,7 +158,7 @@ an approval review with `--checklist-confirmed` and `--checklist-categories`:
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
--body "All 13 checklist categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
```
The `--checklist-confirmed` flag is **required** for APPROVE events —
@@ -164,6 +177,12 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes (see below)
**If the branch is behind master** (another PR merged first), auto-merge
automatically rebases the PR's head branch via the Gitea API. This triggers
a new CI run. The next auto-merge attempt will merge successfully.
No manual rebase needed. To rebase manually: `make rebase` (local) or
`make pr-rebase` (server-side via API).
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
> workflow by adding the `ready-to-merge` label. Manual merges bypass the
> `GRM-N: <conventional>` format enforcement, producing incorrectly named commits.
@@ -172,6 +191,10 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
### CI Path Filtering
The CI workflow includes a `pre-merge-check` job (runs after quality +
detect-changes) that validates branch format, PR title, and Vikunja task
match. This fails fast before expensive molecule tests run.
The CI workflow includes a `detect-changes` job that checks whether any files
under `ansible/` or `.ansible-lint` have changed. If no Ansible files are
changed, molecule tests are skipped — this prevents non-Ansible changes
@@ -243,32 +266,32 @@ Not all changes require the full CI pipeline or a new release. The project
classifies changes into two categories using `devx.ci.classify_changes`:
**Classification strategy (safe-by-default):** Any file NOT in the explicit
workflow-only allowlist is treated as user-facing. This prevents new file
infrastructure allowlist is treated as user-facing. This prevents new file
types from accidentally skipping releases. Classification is config-driven
via `[tool.devx.classify]` in `pyproject.toml`.
**Workflow-only paths** (infrastructure no release needed):
**Infrastructure paths** (no release needed):
- `.gitea/**` — Gitea Actions workflows
- `scripts/**` — Dev tools and CI/CD automation (not part of installed package)
- `docs/**` — Documentation
- `tests/**` — Test files
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `REVIEW_CHECKLIST.md` — Project docs
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md` — Project docs
- `Makefile`, `cliff.toml`, `uv.lock` — Build tooling
- `.pre-commit-config.yaml`, `.ruff.toml`, `.ansible-lint`, `.checkmake.ini`, `.editorconfig` — Lint config
- `.env.example`, `.gitignore`, `.gitattributes` — Config
- `.pre-commit-config.yaml`, `.ansible-lint`, `.checkmake.ini` — Lint config (ruff config is in `pyproject.toml`)
- `.env.example`, `.gitignore` — Config
- `.devin/**` — Agent/CI tooling config
- `hooks/**` — Git hooks
- `activate.sh`, `activate.fish`, `activate.zsh` — Generated venv scripts
**User-facing paths** (tool changes → release needed) — everything else:
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py` and `api_clients.py`)
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py`)
- `ansible/**` — Ansible role
- `pyproject.toml` — Package metadata
- Any new file type not in the allowlist
**devx module structure** (installed from git, not in this repo):
- `devx.ci.*` — CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, validate_commit_msg
- `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges
- `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges, create_task, create_pr, pr_status, pr_logs, pr_label, rebase, pr_rebase
- `devx.molecule.*` — Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guard
- `devx.gitea_cli` — Tea CLI wrapper
- `devx.i18n` — i18n translation system
@@ -284,7 +307,7 @@ via `[tool.devx.classify]` in `pyproject.toml`.
**AI agents must follow these rules:**
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
- Do NOT bump the version or create tags for workflow-only changes
- Do NOT bump the version or create tags for infrastructure-only changes
- The `classify_changes` module enforces this automatically — no manual intervention needed
## Source Code Separation and devx Integration
@@ -398,7 +421,7 @@ The devx package is configured via `DEVX_*` environment variables:
- `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
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the workflow-only 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.
## Key Conventions
@@ -410,6 +433,50 @@ 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)
- 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)
**Rule:** Before pushing any fix that modifies container state (CA certs,
config files, installed packages, daemon restarts), reproduce the exact
sequence locally with the actual Docker image. Do not push to CI as the
first test.
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
ephemeral staging VMs are destroyed after each run, making interactive
debugging impossible. A local reproduction takes 30 seconds and catches
silent failures immediately.
**Procedure:**
1. `docker pull <actual_image>`
2. `docker run -d --name <test> ...` and wait for it to start
3. Run the exact commands from the Ansible task or script
4. Verify the state change took effect
5. Clean up: `docker rm -f <test>`
### Verified State Modification (Mandatory)
Ansible tasks that modify container state with `changed_when: false`
MUST include a post-task verification step that confirms the state
change took effect. `changed_when: false` suppresses both change
detection AND failure visibility — a task can silently do nothing and
report `ok`.
## Ansible Role Structure
```
@@ -423,10 +490,12 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
## Molecule Scenarios
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
7 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`, `remove`
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
Platform list is defined in `devx.molecule.platforms` (single source of truth)
Note: `make molecule` and `make molecule-all` run 6 scenarios (excluding `remove`, which destroys the test container). CI discovers all 7 scenarios via `devx.molecule.distribute_molecule`.
## Known Issues
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
@@ -476,3 +545,86 @@ docs/
2. If adding a new page, add it to `docs/mapping.json`
3. Commit and create a PR (standard PR workflow)
4. On merge, wiki is automatically synced
## Subagent Delegation Policy
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
automatically delegate to the appropriate subagent based on the task —
the user should not need to specify which profile to use.
### Available Profiles
**Global** (shared with infra and devx):
| Profile | Location | Purpose |
|---------|----------|---------|
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
**grm-specific** (in `.devin/agents/`):
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) |
| `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
| `doc-syncer` | Doc coverage, doc linting, wiki sync for grm docs |
| `workflow-validator` | actionlint + act_runner dry-run for grm workflows |
### When to Delegate Automatically
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, molecule-tests, release, publish, sync-wiki) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Molecule tests need to run | `molecule-runner` | Background |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Doc coverage failure or wiki sync issue | `doc-syncer` | Background |
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
| Branch ready for merge | `release-check` | Foreground |
### Delegation Rules
1. **Auto-select the profile.** Do not ask the user which profile to use.
2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
5. **Don't delegate trivial work** (<30s, <50 lines of context).
6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small.
## Feedback Issue Handling
Subagents create Gitea issues in the current repo when they encounter
tool, workflow, or process issues that warrant follow-up. These issues
use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in
post-merge on every master push). If a label does not exist yet, the
subagent's issue creation will still succeed — labels can be added
afterwards.
### When a Subagent Reports a Feedback Issue URL
1. **Acknowledge it** in your response to the user — mention the issue URL
2. **Do NOT close or modify** the issue — it is for follow-up work
3. **Do NOT create a PR** to address it unless the user explicitly asks
4. If the user asks to address feedback, spawn a subagent to investigate
the issue and implement a fix
### Creating Feedback Issues Manually
As the parent agent, you can also create feedback issues directly using
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
same format as subagents:
- Title: `[feedback] <category>: <short description>`
- Labels: `feedback` + category label
- Body: include context, tool/workflow, issue, reproduction, affected
files, suggested investigation, and "Reported by: parent agent"
Always deduplicate first via `list_issues` with `labels: "feedback"`.
+68
View File
@@ -2,6 +2,74 @@
All notable changes to this project will be documented in this file.
## [0.14.2] - 2026-07-05
### Bug Fixes
- Add pre-commit hooks for quality gates matching CI
## [0.14.1] - 2026-07-01
### Refactor
- Align venv management to devx.mak targets
## [0.14.0] - 2026-07-01
### Features
- Bump devx to v0.30.0
## [0.13.0] - 2026-07-01
### Features
- Bump devx to v0.29.1, upgrade molecule, ubuntu 26.04
## [0.12.5] - 2026-06-30
### Bug Fixes
- Right-size molecule-tests matrix to [1-6]
- Cast disk threshold to string in template-content verify assertion
## [0.12.4] - 2026-06-29
### Bug Fixes
- Use hardcoded matrix array for Gitea 1.26 compatibility
## [0.12.3] - 2026-06-29
### Bug Fixes
- Fix wiki link URLs, heading hierarchy, quote pip install vars
- Improve runner service stability and deregistration
## [0.12.2] - 2026-06-28
### Bug Fixes
- Bump devx to 0.26.3 (latest with pinned deps)
## [0.12.1] - 2026-06-28
### Bug Fixes
- Add approval step to auto-merge workflow using REVIEW_GITEA_TOKEN
## [0.12.0] - 2026-06-28
### Features
- Upgrade all dependencies, add trigger-workflow command
## [0.11.1] - 2026-06-28
### Bug Fixes
- Makefile HOST/NAME requirement errors, add restart and list targets
## [0.11.0] - 2026-06-28
### Features
+2
View File
@@ -1,5 +1,7 @@
# Contributing to GRM
For the full contributing guide, see the [Contributing wiki page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing).
Thank you for contributing to Gitea Runner Manager (GRM)!
## Branch Naming
+39 -31
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: create-task create-pr push-with-pr git-push
@@ -25,6 +25,18 @@ DEVX_LINT_PATHS := src/ scripts/ tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
# Fallback: when the venv doesn't exist yet, try system python3 or /opt/venv.
# In CI, /opt/venv has devx pre-installed; locally, devx may be in system python.
ifeq ($(strip $(DEVX_MAK)),)
DEVX_MAK := $(shell python3 -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
endif
ifeq ($(strip $(DEVX_MAK)),)
DEVX_MAK := $(shell /opt/venv/bin/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
endif
-include $(DEVX_MAK)
# Full setup for local development (all deps, tools, collections, hooks)
@@ -77,28 +89,14 @@ setup-image:
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
# Helper: run pip install with Gitea registry configured
# Usage: $(PIP_INSTALL) install -e '.[ci,lint]'
PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
$(BIN)/pip
$(VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip setuptools wheel
.env:
@if [ ! -f .env ]; then \
cp .env.example .env; \
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
activate-scripts: $(VENV)/bin/activate
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
# venv, .env, activate-scripts, and PIP_INSTALL are provided by devx.mak
# (devx-venv, devx-env, devx-activate-scripts, DEVX_PIP_INSTALL)
# Aliases for convenience and backward compatibility:
.PHONY: venv activate-scripts
PIP_INSTALL := $(DEVX_PIP_INSTALL)
venv: devx-venv
.env: devx-env
activate-scripts: devx-activate-scripts
install:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
@@ -109,28 +107,35 @@ update:
$(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
start:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make start NAME=runner1"; exit 1; fi
$(BIN)/grm start $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
stop:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make stop NAME=runner1"; exit 1; fi
$(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
restart:
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make restart NAME=runner1"; exit 1; fi
$(BIN)/grm restart $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
enable:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make enable NAME=runner1"; exit 1; fi
$(BIN)/grm enable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
disable:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make disable NAME=runner1"; exit 1; fi
$(BIN)/grm disable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
status:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make status NAME=runner1"; exit 1; fi
$(BIN)/grm status $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
remove:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make remove NAME=runner1"; exit 1; fi
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(FORCE),--force,) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
list:
$(BIN)/grm list $(if $(NO_STATUS),--no-status,) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
# --- Aliases to devx.mak targets ----------------------------------------------
lint-ruff: devx-lint-ruff
@@ -168,7 +173,10 @@ makefile-lint:
echo "checkmake not found, skipping Makefile lint"; \
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:
$(BIN)/pytest tests/integration/ -v --no-cov
+11 -8
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/python.svg)](https://www.python.org/downloads/)
## Why GRM?
@@ -148,8 +148,12 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc
| `grm disable <name>` | Disable and deregister a runner |
| `grm status <name>` | Check the status of a registered runner |
| `grm remove <name>` | Remove a runner completely (with remote cleanup) |
| `grm remove <name> --force` | Remove only the local registry entry (skip remote cleanup) |
| `grm list` | List all registered runners with live status |
| `grm list --no-status` | List registered runners without SSH status checks |
| `grm health [name]` | Run health check (Docker, runner service, disk) on one or all runners |
| `grm trigger-workflow <workflow_id>` | Trigger a Gitea Actions workflow via the API |
| `grm trigger-workflow --list` | List available workflows in the repository |
| `grm --version` | Show the installed version |
All lifecycle commands (`start`, `stop`, `restart`, `enable`, `disable`, `status`, `remove`) work by runner name and pull connection details from the local registry. You can override any stored value with `--host`, `--user`, or `--key`.
@@ -360,8 +364,7 @@ grm install <host>
| `executor.py` | Ansible subprocess execution with log capture |
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` |
| `i18n.py` | Internationalisation (en, bg, de, ru, zh, pl) |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) |
| `config.py` | Configuration constants (API URLs, repo owner/name) |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) |
| `logging_config.py` | Logging to `~/.local/state/grm/logs/grm.log` |
| `report.py` | Operation report tracking with step status |
| `ui.py` | Colorised console output via Click |
+1 -15
View File
@@ -1,17 +1,3 @@
# Troubleshooting
| Symptom | Likely Cause | Solution |
|---------|-------------|----------|
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
| `devx.tools.configure_repo` fails | CI_GITEA_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |
See the [Troubleshooting guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) in the wiki.
+10
View File
@@ -18,6 +18,16 @@
when: systemd_available.stat.exists
changed_when: true
- name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
+28
View File
@@ -79,6 +79,16 @@
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
- name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Remove docker-prune user service file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.service"
@@ -91,6 +101,24 @@
state: absent
failed_when: false
- name: Remove healthcheck user service file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.service"
state: absent
failed_when: false
- name: Remove healthcheck user timer file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
state: absent
failed_when: false
- name: Remove healthcheck script
ansible.builtin.file:
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}/healthcheck.sh"
state: absent
failed_when: false
- name: Remove systemd user unit file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
+4 -2
View File
@@ -1,5 +1,7 @@
collections:
- name: community.general
version: ">=13.0.1"
version: "==13.1.0"
- name: ansible.posix
version: ">=1.5.4"
version: "==2.2.0"
- name: community.docker
version: "==5.2.1"
+12 -1
View File
@@ -1,6 +1,6 @@
---
gitea_runner_version: "1.0.8"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-22.04"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
skip_runner_registration: false
# Per-runner user (rootless isolation)
@@ -24,6 +24,17 @@ gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Health check configuration
gitea_runner_healthcheck_interval: "5min"
gitea_runner_healthcheck_boot_delay: "2min"
gitea_runner_healthcheck_disk_threshold: 85
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
# Admin token for runner deregistration via Gitea API.
# If not set, falls back to registration_token (which likely lacks admin scope).
# Set this to a token with admin scope to enable automatic runner cleanup on removal.
gitea_admin_token: ""
# Removal defaults
remove_systemd_template: true
remove_runner_user: true
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -75,3 +75,42 @@
that:
- timer_stat.stat.exists
fail_msg: "Docker prune timer is missing"
- name: Check healthcheck script exists
ansible.builtin.stat:
path: "{{ gitea_runner_healthcheck_script_path }}"
register: healthcheck_script_stat
- name: Assert healthcheck script exists
ansible.builtin.assert:
that:
- healthcheck_script_stat.stat.exists
fail_msg: "Healthcheck script is missing"
- name: Assert healthcheck script is executable
ansible.builtin.assert:
that:
- healthcheck_script_stat.stat.mode == "0755"
fail_msg: "Healthcheck script is not executable"
- name: Check healthcheck service exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
register: healthcheck_service_stat
- name: Assert healthcheck service exists
ansible.builtin.assert:
that:
- healthcheck_service_stat.stat.exists
fail_msg: "Healthcheck systemd service is missing"
- name: Check healthcheck timer exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
register: healthcheck_timer_stat
- name: Assert healthcheck timer exists
ansible.builtin.assert:
that:
- healthcheck_timer_stat.stat.exists
fail_msg: "Healthcheck systemd timer is missing"
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -89,6 +89,39 @@
- not prune_timer_stat.stat.exists
fail_msg: "docker-prune timer unit still exists after removal"
- name: Check healthcheck service unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
register: healthcheck_service_stat
- name: Assert healthcheck service unit is absent
ansible.builtin.assert:
that:
- not healthcheck_service_stat.stat.exists
fail_msg: "runner-healthcheck service unit still exists after removal"
- name: Check healthcheck timer unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
register: healthcheck_timer_stat
- name: Assert healthcheck timer unit is absent
ansible.builtin.assert:
that:
- not healthcheck_timer_stat.stat.exists
fail_msg: "runner-healthcheck timer unit still exists after removal"
- name: Check healthcheck script is absent
ansible.builtin.stat:
path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
register: healthcheck_script_stat
- name: Assert healthcheck script is absent
ansible.builtin.assert:
that:
- not healthcheck_script_stat.stat.exists
fail_msg: "healthcheck script still exists after removal"
- name: Check subuid entry is absent
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subuid"
register: subuid_check
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -30,7 +30,10 @@
that:
- "'Type=simple' in service_template.content | b64decode"
- "'ExecStart={{ gitea_runner_binary_path }}' in service_template.content | b64decode"
- "'Restart=on-failure' in service_template.content | b64decode"
- "'Restart=always' in service_template.content | b64decode"
- "'Requires=docker.service' in service_template.content | b64decode"
- "'PartOf=docker.service' in service_template.content | b64decode"
- "'StartLimitBurst=10' in service_template.content | b64decode"
- "'DOCKER_HOST=unix:///run/user' in service_template.content | b64decode"
- "'XDG_RUNTIME_DIR=/run/user' in service_template.content | b64decode"
fail_msg: "User service template is missing expected directives"
@@ -59,3 +62,45 @@
- "'OnCalendar={{ gitea_runner_prune_schedule }}' in prune_timer.content | b64decode"
- "'Persistent=true' in prune_timer.content | b64decode"
fail_msg: "Prune timer template is missing expected directives"
- name: Read rendered healthcheck service template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
register: healthcheck_service
- name: Assert healthcheck service contains expected directives
ansible.builtin.assert:
that:
- "'Type=oneshot' in healthcheck_service.content | b64decode"
- "'ExecStart={{ gitea_runner_healthcheck_script_path }}' in healthcheck_service.content | b64decode"
- "'DOCKER_HOST=unix:///run/user/' in healthcheck_service.content | b64decode"
- "'XDG_RUNTIME_DIR=/run/user/' in healthcheck_service.content | b64decode"
fail_msg: "Healthcheck service template is missing expected directives"
- name: Read rendered healthcheck timer template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
register: healthcheck_timer
- name: Assert healthcheck timer contains expected directives
ansible.builtin.assert:
that:
- "'OnBootSec={{ gitea_runner_healthcheck_boot_delay }}' in healthcheck_timer.content | b64decode"
- "'OnUnitActiveSec={{ gitea_runner_healthcheck_interval }}' in healthcheck_timer.content | b64decode"
- "'Persistent=true' in healthcheck_timer.content | b64decode"
fail_msg: "Healthcheck timer template is missing expected directives"
- name: Read rendered healthcheck script
ansible.builtin.slurp:
src: "{{ gitea_runner_healthcheck_script_path }}"
register: healthcheck_script
- name: Assert healthcheck script contains expected content
ansible.builtin.assert:
that:
- "'docker info' in healthcheck_script.content | b64decode"
- "'systemctl --user restart docker.service' in healthcheck_script.content | b64decode"
- "'systemctl --user restart gitea-runner.service' in healthcheck_script.content | b64decode"
- "'docker system prune' in healthcheck_script.content | b64decode"
- "gitea_runner_healthcheck_disk_threshold | string in healthcheck_script.content | b64decode"
fail_msg: "Healthcheck script template is missing expected content"
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -18,13 +18,11 @@
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner with Gitea via CLI
- name: Deregister runner from Gitea via API
ansible.builtin.command: >
{{ gitea_runner_binary_path }} delete
--token {{ registration_token }}
--name {{ runner_name }}
--instance {{ gitea_url }}
--no-interactive
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
-H "Authorization: token {{ gitea_admin_token | default(registration_token) }}"
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ runner_reg.id }}"
args:
chdir: "{{ gitea_runner_data_dir }}"
become: true
@@ -35,10 +33,24 @@
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
- runner_reg.id is defined
register: deregister_output
changed_when: deregister_output.rc == 0
failed_when: false
- name: Warn if deregistration failed
ansible.builtin.debug:
msg: >-
WARNING: Runner deregistration from Gitea failed (rc={{ deregister_output.rc | default('N/A') }}).
The runner entry may remain in Gitea's admin UI as offline.
Use an admin token (gitea_admin_token var) to enable automatic cleanup,
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
- deregister_output is defined
- deregister_output.rc | default(1) != 0
- name: Remove runner registration file
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}/.runner"
@@ -0,0 +1,46 @@
---
- name: Create healthcheck script
ansible.builtin.template:
src: runner-healthcheck.sh.j2
dest: "{{ gitea_runner_healthcheck_script_path }}"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Create healthcheck user service file
ansible.builtin.template:
src: runner-healthcheck.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Create healthcheck user timer file
ansible.builtin.template:
src: runner-healthcheck.timer.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Reload systemd user daemon for healthcheck timer
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Enable and start healthcheck user timer
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
@@ -14,6 +14,9 @@
- name: Include prune setup
ansible.builtin.include_tasks: prune.yml
- name: Include healthcheck setup
ansible.builtin.include_tasks: healthcheck.yml
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not skip_runner_registration
@@ -6,4 +6,4 @@ Type=oneshot
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStart=/usr/bin/docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}"
@@ -1,6 +1,8 @@
[Unit]
Description=Gitea Actions Runner (rootless)
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=simple
@@ -10,8 +12,10 @@ Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStop=/bin/kill -TERM $MAINPID
TimeoutStopSec=30
Restart=on-failure
Restart=always
RestartSec={{ gitea_runner_service_restart_sec }}
StartLimitIntervalSec=300
StartLimitBurst=10
[Install]
WantedBy=default.target
@@ -0,0 +1,9 @@
[Unit]
Description=Gitea Runner health check (Docker + service + disk)
After=docker.service gitea-runner.service
[Service]
Type=oneshot
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStart={{ gitea_runner_healthcheck_script_path }}
@@ -0,0 +1,49 @@
#!/bin/bash
# Health check for gitea-runner: verifies Docker daemon and runner service.
# Exits 0 if healthy, 1 if Docker is down (triggers restart), 2 if runner is down.
set -euo pipefail
DOCKER_HOST="unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR="/run/user/{{ gitea_runner_uid }}"
export DOCKER_HOST XDG_RUNTIME_DIR
# 1. Check Docker daemon responsiveness
if ! docker info >/dev/null 2>&1; then
echo "ERROR: Docker daemon not responding at ${DOCKER_HOST}"
systemctl --user restart docker.service
sleep 3
if ! docker info >/dev/null 2>&1; then
echo "CRITICAL: Docker daemon still down after restart"
exit 1
fi
echo "RECOVERED: Docker daemon restarted successfully"
fi
# 2. Check gitea-runner service is active
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
if [[ "$runner_state" != "active" ]]; then
echo "ERROR: gitea-runner service is ${runner_state}, restarting"
systemctl --user restart gitea-runner.service
sleep 2
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
if [[ "$runner_state" != "active" ]]; then
echo "CRITICAL: gitea-runner service still down after restart"
exit 2
fi
echo "RECOVERED: gitea-runner service restarted successfully"
fi
# 3. Check disk space — prune aggressively if below threshold
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then
echo "WARN: Disk usage at ${disk_pct}%, pruning all runner resources"
docker system prune -af --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true
docker volume prune -af --filter "label={{ gitea_runner_prune_label }}" || true
# Also prune dangling images (no label)
docker image prune -af || true
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
echo "INFO: Disk usage after prune: ${disk_pct}%"
fi
echo "OK: runner healthy, disk at ${disk_pct}%"
exit 0
@@ -0,0 +1,10 @@
[Unit]
Description=Periodic Gitea Runner health check
[Timer]
OnBootSec={{ gitea_runner_healthcheck_boot_delay }}
OnUnitActiveSec={{ gitea_runner_healthcheck_interval }}
Persistent=true
[Install]
WantedBy=timers.target
+13 -13
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bdbbfb7b82a9721d5d103a1008dde317d19a82b8/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -42,20 +42,20 @@ All supported OSes are tested in CI via Molecule scenarios on every PR that chan
## User Documentation
- [Getting Started](Getting-Started.-) — Installation, quick start, token setup, first run, log viewing
- [Getting Started](Getting-Started) — Installation, quick start, token setup, first run, log viewing
- [Installation](Installation) — Prerequisites, setup methods, multiple instances, runner registry
- [CLI Commands](CLI-Commands.-) — All commands with arguments, options, and examples
- [CLI Commands](CLI-Commands) — All commands with arguments, options, and examples
- [Troubleshooting](Troubleshooting) — Common issues, diagnostics, and solutions
- [FAQ](FAQ) — Frequently asked questions
## Technical Documentation
- [Architecture](Architecture) — High-level design, component diagram, data flow, security model, per-runner isolation
- [Development Setup](Development-Setup.-) — Environment setup, project structure, dependencies, linting, testing
- [CI/CD Workflow](CI-CD-Workflow.-) — PR workflow, branch protection, release pipeline, change classification, badge generation
- [Testing Strategy](Testing-Strategy.-) — Unit tests, Molecule scenarios, integration tests, CI distribution
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale (ADRs)
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit conventions, Ansible role conventions
- [Development Setup](Development-Setup) — Environment setup, project structure, dependencies, linting, testing
- [CI/CD Workflow](CI-CD-Workflow) — PR workflow, branch protection, release pipeline, change classification, badge generation
- [Testing Strategy](Testing-Strategy) — Unit tests, Molecule scenarios, integration tests, CI distribution
- [Decision Log](Decision-Log) — Key technical decisions and rationale (ADRs)
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit conventions, Ansible role conventions
## Quick Links
+8 -4
View File
@@ -31,13 +31,14 @@ grm install <host>
├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service)
├── prune.yml (Docker prune timer)
├── healthcheck.yml (health check script + systemd timer)
└── integration_test.yml (validate service is active)
```
The Ansible role task execution order (from `AGENTS.md`):
```
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → healthcheck → integration_test
```
- `install_runner.yml` handles: download, config, validate, register, service
@@ -59,6 +60,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
| `register.yml` | Registers the runner with Gitea using the registration token |
| `service.yml` | Creates the systemd user service file and starts/enables the service |
| `prune.yml` | Creates a systemd user timer for daily Docker image and volume pruning |
| `healthcheck.yml` | Installs a health check script and systemd timer that monitors Docker daemon, runner service, and disk space; restarts unhealthy services automatically |
| `integration_test.yml` | Verifies the `.runner` file exists and the systemd service is active; optionally queries the Gitea API |
| `deregister.yml` | Deregisters the runner from Gitea and removes the `.runner` file |
| `update_runner.yml` | Downloads a new version of the gitea_runner binary |
@@ -71,6 +73,9 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
| `gitea-runner-config.yaml.j2` | Runner configuration file (labels, capacity, log level) |
| `docker-prune.service.j2` | Systemd user service for Docker pruning (oneshot) |
| `docker-prune.timer.j2` | Systemd user timer triggering daily Docker prune |
| `runner-healthcheck.sh.j2` | Health check script (checks Docker, runner service, disk space; restarts if down) |
| `runner-healthcheck.service.j2` | Systemd user service for the health check (oneshot) |
| `runner-healthcheck.timer.j2` | Systemd user timer triggering periodic health checks |
## Per-Runner Isolation
@@ -139,6 +144,7 @@ flowchart TD
- Registers the runner with Gitea
- Creates and starts the systemd user service
- Sets up the Docker prune timer
- Installs the health check script and systemd timer
- Runs the integration test (verifies `.runner` file and service state)
7. Ansible output is streamed to a timestamped log file at `~/.local/state/grm/logs/ansible-<timestamp>.log`
8. On success, the runner is added to the local registry at `~/.local/share/grm/runners.json`
@@ -210,12 +216,10 @@ The Python CLI layer (`src/gitea_runner_manager/`) consists of the following mod
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files, streams output to log files |
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` — stores connection metadata |
| `i18n.py` | Internationalisation translations (en, bg, de, ru, zh, pl) — opt-in via `GRM_LANG` environment variable |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) |
| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) |
| `logging_config.py` | Logging configuration — writes all messages to `~/.local/state/grm/logs/grm.log` at DEBUG level |
| `report.py` | Operation report tracking — prints a step-by-step report with status icons after each command |
| `ui.py` | User-facing output utilities — colorised console output via `click.style`, with log file always receiving plain text |
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts (not used by the CLI itself) |
| `translations.json` | Translation strings for all supported languages |
## Logging
+2 -2
View File
@@ -155,7 +155,7 @@ Once all comments are addressed, post an approval review:
CI_GITEA_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 REVIEW_CHECKLIST.md categories verified."
--body "All 13 checklist categories verified."
```
Then add the `ready-to-merge` label. The auto-merge workflow will:
@@ -216,7 +216,7 @@ Not all changes require a new release. The project classifies changes using `dev
- Lint config files, `.env.example`, `.gitignore`
**User-facing paths** (release needed):
- `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`)
- `src/gitea_runner_manager/**` (except `__init__.py`)
- `ansible/**`
- `pyproject.toml`
+1 -1
View File
@@ -94,7 +94,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Classify changed files into user-facing and workflow-only categories using `devx.ci.classify_changes`. Only user-facing changes trigger a release; workflow-only changes (CI, docs, tests, lint config) do not.
**Rationale:** Not all changes require a new release. CI workflow updates, documentation improvements, and test additions should not produce a new version tag. The classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`. The strategy is safe-by-default: any file NOT in the explicit workflow-only allowlist is treated as user-facing, preventing new file types from accidentally skipping releases. User-facing paths include `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`) and `ansible/**`. Workflow-only paths include `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`, and various config files.
**Rationale:** Not all changes require a new release. CI workflow updates, documentation improvements, and test additions should not produce a new version tag. The classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`. The strategy is safe-by-default: any file NOT in the explicit workflow-only allowlist is treated as user-facing, preventing new file types from accidentally skipping releases. User-facing paths include `src/gitea_runner_manager/**` (except `__init__.py`) and `ansible/**`. Workflow-only paths include `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`, and various config files.
**Source:** `AGENTS.md` (Smart CI: User-Facing vs Workflow-Only Changes), `pyproject.toml` (`[tool.devx.classify]`)
-2
View File
@@ -11,11 +11,9 @@
│ ├── registry.py # Local JSON runner registry
│ ├── i18n.py # Translations (en, bg, de, ru, zh, pl)
│ ├── exceptions.py # Custom exceptions
│ ├── config.py # Configuration constants
│ ├── logging_config.py # Logging to ~/.local/state/grm/logs/
│ ├── report.py # Operation report tracking
│ ├── ui.py # Colorised console output
│ ├── api_clients.py # Gitea/Vikunja API clients (for CI scripts)
│ └── translations.json # Translation strings
├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role
+1 -1
View File
@@ -83,7 +83,7 @@ The platform list is defined in `devx.molecule.platforms` (single source of trut
### CI Test Distribution
CI runs all 6 scenarios x 4 platforms (24 test pairs) distributed across available Gitea Actions runners.
CI runs all 7 scenarios x 4 platforms (28 test pairs) distributed across available Gitea Actions runners.
The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for registered runners at three levels (repo, org, instance) and generates a dynamic matrix. If the API query fails (e.g., no admin access for instance-level runners), it falls back to the `MOLECULE_RUNNERS` repo variable, then to a default of 3.
+91 -1
View File
@@ -10,11 +10,14 @@ GRM provides the following CLI commands for managing Gitea Actions runners. The
| `grm update` | `<host>` | Update the gitea_runner binary on a remote host |
| `grm start` | `<runner_name>` | Start a registered runner |
| `grm stop` | `<runner_name>` | Stop a registered runner |
| `grm restart` | `<runner_name>` | Restart a runner (stop, prune Docker images, start) |
| `grm enable` | `<runner_name>` | Enable a runner to start on boot |
| `grm disable` | `<runner_name>` | Disable and deregister a runner |
| `grm status` | `<runner_name>` | Check the status of a registered runner |
| `grm remove` | `<runner_name>` | Remove a runner completely |
| `grm list` | — | List all registered runners with live status |
| `grm health` | `[runner_name]` | Run health check (Docker, runner service, disk) on one or all runners |
| `grm trigger-workflow` | `<workflow_id>` | Trigger a Gitea Actions workflow via the API |
| `grm --version` | — | Show the installed version |
### Common lifecycle options
@@ -139,6 +142,29 @@ grm stop <runner_name> [options]
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## restart
Restart a registered Gitea Runner (stop, prune Docker images, start).
```bash
grm restart <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## enable
Enable a registered Gitea Runner to start on boot.
@@ -276,6 +302,70 @@ If no runners are registered:
No runners registered. Use 'grm install' to add one.
```
## health
Run a health check on one or all registered runners. Checks Docker daemon status, Gitea runner service status, and disk space usage. Unhealthy services are automatically restarted by the healthcheck script.
```bash
grm health [runner_name] [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | (optional) Name of the runner to check. If omitted, checks all registered runners. |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
**Example:**
```bash
grm health
# Check a specific runner:
grm health prod-runner
```
Output shows NAME, HOST, HEALTHY (yes/no), and MESSAGE columns. The command exits with code 1 if any runner is unhealthy.
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea-runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
## trigger-workflow
Trigger a Gitea Actions workflow via the API.
```bash
grm trigger-workflow <workflow_id> [options]
grm trigger-workflow --list
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `workflow_id` | Workflow filename (e.g., `ci.yml`) or ID |
**Options:**
| Option | Description |
|--------|-------------|
| `--list` | List available workflows in the repository |
| `--ref` | Branch or tag to trigger on (default: repository default branch) |
**Example:**
```bash
grm trigger-workflow --list
grm trigger-workflow ci.yml --ref master
```
## --version
Show the installed GRM version.
@@ -299,5 +389,5 @@ All CLI options can be set via environment variables (loaded from `.env` via pyt
| `GITEA_RUNNER_USER` | `install`, `update` | Default SSH user |
| `GITEA_RUNNER_KEY` | `install`, `update` | Default SSH key path |
| `GITEA_RUNNER_LABELS` | `install` | Default runner labels |
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh` |
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
| `GRM_LOG_LEVEL` | all | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
+18 -18
View File
@@ -1,6 +1,6 @@
# FAQ
### How do I obtain the Gitea registration token?
## How do I obtain the Gitea registration token?
There are three levels of registration tokens, depending on which repositories the runner should serve:
@@ -10,7 +10,7 @@ There are three levels of registration tokens, depending on which repositories t
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
### What is the CI_GITEA_TOKEN and do I need it?
## What is the CI_GITEA_TOKEN and do I need it?
`CI_GITEA_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
@@ -18,7 +18,7 @@ To generate one: Settings → Applications → Generate New Token, with the `adm
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
### How do I skip the sudo password prompt for automation?
## How do I skip the sudo password prompt for automation?
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
@@ -34,7 +34,7 @@ Then use:
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner --no-ask-become-pass
```
### Can I run multiple runners on the same host?
## Can I run multiple runners on the same host?
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
@@ -46,7 +46,7 @@ grm list
Runners on the same host never interfere with each other or with the host's Docker installation.
### Why does my runner appear offline after installation?
## Why does my runner appear offline after installation?
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
@@ -56,15 +56,15 @@ Common causes:
- Rootless Docker daemon not running — check `sudo -u grm-<name> systemctl --user status docker`
- Lingering not enabled — check `loginctl show-user grm-<name> | grep Linger`
### What does the "Event loop is closed" warning mean?
## What does the "Event loop is closed" warning mean?
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
### Where are runner connection details stored?
## Where are runner connection details stored?
GRM stores each runner's connection details (host, user, SSH key, Gitea URL, labels) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
### How do I update the gitea_runner binary?
## How do I update the gitea_runner binary?
Use the `grm update` command:
@@ -80,7 +80,7 @@ grm update 192.168.1.10 --user ubuntu --version 1.0.8
The update command downloads the new binary and replaces the existing one at `/usr/local/bin/gitea_runner`. The runner service is restarted automatically.
### How do I completely remove a runner?
## How do I completely remove a runner?
Use the `grm remove` command:
@@ -96,18 +96,18 @@ If the remote host is already gone or unreachable, use `--force` to skip remote
grm remove prod-runner --force
```
### What is the difference between disable and remove?
## What is the difference between disable and remove?
- **`grm disable <name>`** — Deregisters the runner from Gitea and stops the service, but leaves the user, directories, and service files in place. The runner can be re-enabled later with `grm enable` and re-registered with a new token.
- **`grm remove <name>`** — Completely removes the runner: deregisters from Gitea, stops and disables the service, removes the system user, deletes all directories, and removes the local registry entry. This is irreversible.
### What operating systems are supported?
## What operating systems are supported?
GRM supports Arch Linux (rolling), Ubuntu 22.04/24.04, and Debian 12. All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
### How do I change the UI language?
## How do I change the UI language?
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese).
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese), `pl` (Polish).
```bash
GRM_LANG=bg grm install 192.168.1.10 --user ubuntu --name prod-runner
@@ -119,7 +119,7 @@ Or set it in your `.env` file:
GRM_LANG=bg
```
### How do I enable debug logging?
## How do I enable debug logging?
Set the `GRM_LOG_LEVEL` environment variable to `DEBUG`:
@@ -129,7 +129,7 @@ GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting. Ansible execution logs are stored in timestamped files at `~/.local/state/grm/logs/ansible-<timestamp>.log`.
### What runner labels should I use?
## What runner labels should I use?
By default, runners are registered with `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels` or the `GITEA_RUNNER_LABELS` environment variable.
@@ -142,7 +142,7 @@ grm install 192.168.1.10 --user ubuntu --name prod-runner \
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
```
### Is GRM secure?
## Is GRM secure?
Yes. GRM is designed with security as a first-class concern:
@@ -151,7 +151,7 @@ Yes. GRM is designed with security as a first-class concern:
- **No shell injection**: The CLI never uses `shell=True` with subprocess.
- **Bandit security scan**: The CI pipeline runs Bandit on every PR.
### Can I install GRM via pip?
## Can I install GRM via pip?
Yes:
@@ -161,7 +161,7 @@ pip install gitea-runner-manager
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.
### How does GRM handle idempotence?
## How does GRM handle idempotence?
The Ansible role is idempotent — running `grm install` twice produces zero changes on the second run. Each task checks for existing state before making changes. For example:
+3 -3
View File
@@ -98,7 +98,7 @@ CI_GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
| `GITEA_RUNNER_USER` | No | current login | Default SSH user (overrides `--user`) |
| `GITEA_RUNNER_KEY` | No | — | Default SSH key path (overrides `--key`) |
| `GITEA_RUNNER_LABELS` | No | — | Default runner labels (overrides `--labels`) |
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh` |
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
| `GRM_LOG_LEVEL` | No | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
## Step 3: Install Your First Runner
@@ -182,7 +182,7 @@ grm disable prod-runner # Disable and deregister the runner
grm remove prod-runner # Remove the runner completely
```
See [CLI Commands](CLI-Commands.-) for the full command reference.
See [CLI Commands](CLI-Commands) for the full command reference.
## View Logs
@@ -232,6 +232,6 @@ Console output is automatically colorised via `click.style`: operation headers i
## Next Steps
- **Install more runners** on the same or different hosts — see [Installation](Installation)
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands.-)
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands)
- **Troubleshoot issues** — see [Troubleshooting](Troubleshooting)
- **Understand the architecture** — see [Architecture](Architecture)
+3 -3
View File
@@ -1,6 +1,6 @@
# Installation
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started.-) for setup instructions. Do not run from `master` — it may contain unreleased changes.
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started) for setup instructions. Do not run from `master` — it may contain unreleased changes.
## Prerequisites
@@ -14,7 +14,7 @@
- **SSH server** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started.-) for detailed instructions on obtaining tokens.
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started) for detailed instructions on obtaining tokens.
- **systemd** — Required for user services and lingering. All supported OSes ship with systemd.
- **Docker** — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
@@ -96,7 +96,7 @@ Required variables:
| `GITEA_URL` | Your Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Runner registration token from Gitea (starts with `GR`) |
See [Getting Started](Getting-Started.-) for detailed token setup instructions.
See [Getting Started](Getting-Started) for detailed token setup instructions.
## Quick Start Install
+18 -22
View File
@@ -14,10 +14,9 @@ classifiers = [
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
]
dependencies = [
"requests>=2.34.2",
"python-dotenv>=1.2.2",
"click>=8.4.1",
"ansible>=14.0.0",
"python-dotenv==1.2.2",
"click==8.4.2",
"ansible==14.1.0",
]
[project.scripts]
@@ -27,35 +26,35 @@ grm = "gitea_runner_manager.cli:cli"
version = {attr = "gitea_runner_manager.__version__"}
[project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests
# Minimal deps for CI scripts that only need click/dotenv
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"build>=1.5.0",
"twine>=6.2.0",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"build==1.5.0",
"twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx>=0.26.0",
"devx==0.33.1",
]
# Lint and type-checking tools (quality job)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
"bandit>=1.8.2",
"pip-audit>=2.10",
"pre-commit>=4.6.0",
"ansible-lint>=26.4.0",
"ruff==0.15.20",
"pyright==1.1.411",
"bandit==1.9.4",
"pip-audit==2.10.1",
"pre-commit==4.6.0",
"ansible-lint==26.4.0",
]
# Molecule testing (molecule-tests job)
molecule = [
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"molecule==26.6.0",
"molecule-docker==2.1.0",
]
# Full dev environment (local development, includes everything)
dev = [
"gitea-runner-manager[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx>=0.26.0",
"devx==0.33.1",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
@@ -128,11 +127,8 @@ infrastructure = ["scripts/**"]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure:
# - __init__.py: only contains __version__ (set by release.py, not user code)
# - api_clients.py: used only by tests and legacy CI scripts (now in devx),
# not by the grm CLI tool itself
infrastructure_overrides = [
"src/gitea_runner_manager/__init__.py",
"src/gitea_runner_manager/api_clients.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.11.0"
__version__ = "0.14.2"
-353
View File
@@ -1,353 +0,0 @@
"""Reusable HTTP API clients for Gitea and Vikunja."""
from __future__ import annotations
import logging
import time
from typing import Any
import requests
from .config import DEFAULT_TIMEOUT
from .exceptions import APIError
logger = logging.getLogger("grm")
# Retry configuration for transient errors (429, 5xx, connection errors)
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
"""Extract status code and message from an HTTPError response."""
response = getattr(e, "response", None)
status = response.status_code if response is not None else 0
try:
body: dict[str, Any] = response.json() if response is not None else {}
message: str = body.get("message", str(e))
except Exception:
message = str(e)
return status, message
def _is_retryable(e: Exception) -> bool:
"""Check if an exception is a transient error worth retrying."""
if isinstance(e, requests.ConnectionError):
return True
if isinstance(e, requests.HTTPError):
status, _ = _parse_error(e)
return status in RETRY_STATUS_CODES
return isinstance(e, requests.Timeout)
class GiteaClient:
"""Low-level Gitea REST API client with connection pooling."""
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
self._base_url = base_url.rstrip("/")
self._owner = owner
self._repo = repo
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
)
def _url(self, path: str) -> str:
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path)
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
# Should not reach here, but just in case
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
# -- repo settings --
def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
"""Update repository settings (e.g. auto-delete branch after merge)."""
r = self._request("PATCH", "", json=settings)
return r.json()
# -- branch protection --
def list_branch_protections(self) -> list[dict[str, Any]]:
r = self._request("GET", "/branch_protections")
return r.json()
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("POST", "/branch_protections", json=config)
return r.json()
def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("PATCH", f"/branch_protections/{branch}", json=config)
return r.json()
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
"""Idempotent: create or update branch protection for the given branch."""
existing = self.list_branch_protections()
for p in existing:
if p.get("branch_name") == branch:
update_config = {k: v for k, v in config.items() if k != "branch_name"}
return self.update_branch_protection(branch, update_config)
return self.create_branch_protection(config)
# -- labels --
def list_labels(self) -> list[dict[str, Any]]:
r = self._request("GET", "/labels")
return r.json()
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
r = self._request(
"POST",
"/labels",
json={"name": name, "color": color, "description": description},
)
return r.json()
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
"""Idempotent: create label if it doesn't already exist."""
labels = self.list_labels()
for label in labels:
if label["name"] == name:
return None
return self.create_label(name, color, description)
def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]:
"""Create a new issue in the repository.
Args:
labels: List of label IDs (integers, not names).
"""
payload: dict[str, Any] = {"title": title, "body": body}
if labels:
payload["labels"] = labels
r = self._request("POST", "/issues", json=payload)
return r.json()
# -- pulls / releases --
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch labels currently attached to a pull request."""
r = self._request("GET", f"/issues/{pr_number}/labels")
return r.json()
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit.
Uses the combined status endpoint (/commits/{sha}/status) which
returns one entry per context (the latest), deduplicated server-side.
The plural endpoint (/commits/{sha}/statuses) returns every historical
entry including stale "pending" ones that never got updated.
"""
r = self._request("GET", f"/commits/{sha}/status")
data = r.json()
return data.get("statuses", [])
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
"""Fetch pull request details including mergeable state."""
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the list of files changed in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/files")
return r.json()
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the commits included in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/commits")
return r.json()
def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch reviews posted on a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/reviews")
return r.json()
def create_review(
self,
pr_number: str | int,
event: str = "COMMENT",
body: str = "",
comments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Post a review on a pull request.
Args:
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
body: Top-level review body text.
comments: Line-level comments with ``path``, ``body``,
``new_position`` (and optionally ``old_position``).
"""
# Map common event names to Gitea API values
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
gitea_event = event_map.get(event, event)
payload: dict[str, Any] = {"event": gitea_event, "body": body}
if comments:
payload["comments"] = comments
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
return r.json()
def create_release(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
payload = {
"tag_name": tag,
"name": name or tag,
"body": body,
"draft": draft,
"prerelease": prerelease,
}
r = self._request("POST", "/releases", json=payload)
return r.json()
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
"""Fetch a release by its tag name. Returns None if not found."""
try:
r = self._request("GET", f"/releases/tags/{tag}")
return r.json()
except APIError:
return None
def create_release_idempotent(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
"""Create a release, or return the existing one if it already exists.
This is idempotent safe to call multiple times for the same tag.
"""
existing = self.get_release_by_tag(tag)
if existing:
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
return existing
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling."""
def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/")
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {token}"})
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}"
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params)
return r.json()
def get_task(self, task_id: int) -> dict[str, Any]:
"""Fetch a single task by its numeric ID."""
r = self._request("GET", f"/tasks/{task_id}")
return r.json()
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
"""List tasks in a specific project (more efficient than listing all tasks)."""
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
def update_task(self, task_id: int, **fields: Any) -> None:
self._request("POST", f"/tasks/{task_id}", json=fields)
+118
View File
@@ -14,6 +14,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from . import __version__
from .exceptions import GRMError
from .gitea_client import GiteaAPIError, GiteaWorkflowClient
from .i18n import _
from .runner_manager import RunnerManager
@@ -441,6 +442,44 @@ def _collect_become_pass(ask_become_pass: bool) -> str | None:
return sys.stdin.readline().strip() or None
@cli.command(name="health", help=_("Run health check on one or all registered runners."))
@click.argument("runner_name", required=False)
@_runner_options
@_handle_errors("Health check failed: {error}")
def health(
runner_name: str | None,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
"""Check Docker, runner service, and disk health on remote hosts."""
become_pass = _collect_become_pass(ask_become_pass)
manager = RunnerManager()
results = manager.health(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_pass=become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
if not results:
click.echo(_("No runners registered. Use 'grm install' to add one."))
return
click.echo(f"{_('NAME'):<18} {_('HOST'):<16} {_('HEALTHY'):<10} {_('MESSAGE')}")
click.echo("-" * 80)
all_healthy = True
for r in results:
if r["healthy"] != "yes":
all_healthy = False
click.echo(f"{r['name']:<18} {r['host']:<16} {r['healthy']:<10} {r['message']}")
if not all_healthy:
raise click.ClickException(_("One or more runners are unhealthy"))
@cli.command(name="list", help=_("List all registered runners with live status."))
@click.option(
"--ask-become-pass/--no-ask-become-pass",
@@ -468,3 +507,82 @@ def list_runners(ask_become_pass: bool, no_status: bool) -> None:
click.echo("-" * 90)
for r in runners:
click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['labels']:<30} {r['status']}")
@cli.command(name="trigger-workflow", help=_("Trigger a Gitea Actions workflow via the API."))
@click.argument("workflow_id", required=False)
@click.option(
"--repo",
default=lambda: os.getenv("GRM_REPO", "oblachno-oss/grm"),
help=_("Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)"),
)
@click.option(
"--ref",
default="master",
help=_("Git ref to run the workflow on (default: master)"),
)
@click.option(
"--url",
default=lambda: os.getenv("GITEA_URL", ""),
help=_("Gitea URL (env: GITEA_URL)"),
)
@click.option(
"--token",
default=lambda: os.getenv("CI_GITEA_TOKEN"),
help=_("Gitea API token (env: CI_GITEA_TOKEN)"),
)
@click.option(
"--list",
"list_only",
is_flag=True,
default=False,
help=_("List available workflows instead of triggering one"),
)
def trigger_workflow(
workflow_id: str,
repo: str,
ref: str,
url: str,
token: str | None,
list_only: bool,
) -> None:
"""Trigger a Gitea Actions workflow dispatch event."""
if not url:
raise click.ClickException(_("GITEA_URL is required (set --url or GITEA_URL env var)"))
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)"))
if not list_only and not workflow_id:
raise click.ClickException(_("WORKFLOW_ID is required unless --list is used"))
client = GiteaWorkflowClient(url, token)
owner, repo_name = repo.split("/", 1)
if list_only:
workflows = client.list_workflows(owner, repo_name)
if not workflows:
click.echo(_("No workflows found in {repo}", repo=repo))
return
click.echo(f"{'ID':<30} {'NAME':<20} {'PATH':<25} {'STATE'}")
click.echo("-" * 85)
for wf in workflows:
wf_id = str(wf.get("id", ""))
wf_name = wf.get("name", "")
wf_path = wf.get("path", "")
wf_state = wf.get("state", "")
click.echo(f"{wf_id:<30} {wf_name:<20} {wf_path:<25} {wf_state}")
return
click.echo(_("Triggering workflow {wf} on {repo}@{ref}...", wf=workflow_id, repo=repo, ref=ref))
try:
result = client.dispatch_workflow(owner, repo_name, workflow_id, ref)
except GiteaAPIError as e:
raise click.ClickException(str(e)) from e
if result and result.get("id"):
run_id: Any = result["id"]
click.echo(_("Workflow triggered successfully. Run ID: {run_id}", run_id=run_id))
if result.get("html_url"):
html_url: Any = result["html_url"]
click.echo(f" {html_url}")
else:
click.echo(_("Workflow triggered successfully."))
-43
View File
@@ -1,43 +0,0 @@
"""Shared configuration constants for GRM scripts and API clients."""
from __future__ import annotations
import os
import re
GITEA_API_URL = os.getenv("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("GRM_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
REPO_OWNER = os.getenv("GRM_REPO_OWNER", "oblachno-oss")
REPO_NAME = os.getenv("GRM_REPO_NAME", "grm")
VIKUNJA_PROJECT_ID = int(os.getenv("GRM_VIKUNJA_PROJECT_ID", "6"))
TASK_ID_RE = re.compile(r"GRM-\d+")
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
DEFAULT_TIMEOUT = 30
DEFAULT_PER_PAGE = 50
BRANCH_PROTECTION_CONFIG: dict[str, object] = {
"branch_name": "master",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": ["emil"],
"enable_status_check": True,
"status_check_contexts": [
"CI / quality (pull_request)",
"CI / molecule-tests (1) (pull_request)",
"CI / molecule-tests (2) (pull_request)",
"CI / molecule-tests (3) (pull_request)",
],
"required_approvals": 0,
"dismiss_stale_approvals": True,
"block_on_outdated_branch": True,
"block_on_rejected_reviews": True,
"block_on_official_review_requests": True,
}
REPO_SETTINGS_CONFIG: dict[str, object] = {
"default_delete_branch_after_merge": True,
}
-9
View File
@@ -11,12 +11,3 @@ class AnsibleError(GRMError):
"""Raised when an Ansible command fails."""
pass
class APIError(GRMError):
"""Raised when a REST API call returns an HTTP error."""
def __init__(self, status: int, message: str) -> None:
self.status = status
self.message = message
super().__init__(f"HTTP {status}: {message}")
+86
View File
@@ -0,0 +1,86 @@
"""Minimal Gitea API client for workflow operations.
Uses urllib from the standard library to avoid adding requests as a
runtime dependency. Only covers the Actions workflow dispatch endpoint.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request # noqa: PTH123 # nosec B404
from contextlib import suppress
from typing import Any
class GiteaAPIError(Exception):
"""Raised when a Gitea API call fails."""
def __init__(self, status: int, message: str) -> None:
super().__init__(f"Gitea API error {status}: {message}")
self.status = status
self.message = message
class GiteaWorkflowClient:
"""Thin client for Gitea Actions workflow API endpoints."""
def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/")
self._token = token
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any] | None:
url = f"{self._base_url}/api/v1{path}"
data = json.dumps(body).encode("utf-8") if body else None
req = urllib.request.Request( # nosec B310
url,
data=data,
method=method,
)
req.add_header("Authorization", f"token {self._token}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req) as resp: # noqa: PTH123 # nosec B310
if resp.status == 204:
return None
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
with suppress(json.JSONDecodeError, ValueError):
detail = json.loads(detail).get("message", detail)
raise GiteaAPIError(e.code, detail) from e
def list_workflows(self, owner: str, repo: str) -> list[dict[str, Any]]:
"""List all workflows in a repository."""
result = self._request("GET", f"/repos/{owner}/{repo}/actions/workflows")
if result is None:
return []
return result.get("workflows", [])
def dispatch_workflow(
self,
owner: str,
repo: str,
workflow_id: str,
ref: str = "master",
inputs: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""Trigger a workflow dispatch event.
Args:
owner: Repository owner.
repo: Repository name.
workflow_id: Workflow file name (e.g. "ci.yml") or numeric ID.
ref: Git ref (branch/tag) to run on. Defaults to "master".
inputs: Optional workflow inputs.
Returns:
Run details dict if return_run_details is requested, else None.
"""
path = f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches?return_run_details=true"
body: dict[str, Any] = {"ref": ref}
if inputs:
body["inputs"] = inputs
return self._request("POST", path, body)
+64 -3
View File
@@ -2,12 +2,11 @@
from __future__ import annotations
import contextlib
import json
import os
import tempfile
from collections.abc import Generator
from contextlib import contextmanager
from contextlib import contextmanager, suppress
from pathlib import Path
from .exceptions import AnsibleError
@@ -47,7 +46,7 @@ class RunnerManager:
json.dump(extra_vars, f)
yield path
finally:
with contextlib.suppress(FileNotFoundError):
with suppress(FileNotFoundError):
os.unlink(path)
def _run_playbook(
@@ -488,6 +487,68 @@ class RunnerManager:
)
return result
def health(
self,
name: str | None = None,
host: str | None = None,
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_pass: str | None = None,
become_password_file: str | None = None,
verbose: bool = False,
) -> list[dict[str, str]]:
"""Run health check on one or all registered runners.
When *name* is provided, checks only that runner. Otherwise,
checks all registered runners. Returns a list of dicts with
``name``, ``host``, ``healthy`` (``"yes"``/``"no"``), and
``message`` keys.
"""
if name:
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
entries = [(name, actual_host, actual_user, actual_key)]
else:
entries = [(n, info["host"], info["user"], info.get("key")) for n, info in self._registry.list().items()]
results: list[dict[str, str]] = []
for runner_name, r_host, r_user, r_key in entries:
say(_("Checking health of {name} on {host}", name=runner_name, host=r_host))
healthy = "no"
message = "unknown"
try:
stdout = self._executor.run_ad_hoc(
r_host,
r_user,
r_key,
"shell",
f"sudo -u grm-{runner_name} "
f"XDG_RUNTIME_DIR=/run/user/$(id -u grm-{runner_name}) "
f"systemctl --user start runner-healthcheck.service && "
f"journalctl --user -u runner-healthcheck.service --no-pager -n 1",
become=True,
ask_become_pass=ask_become_pass or become_password_file is not None,
check=False,
become_pass=become_pass,
)
if "OK:" in stdout:
healthy = "yes"
# Extract the OK line
for line in stdout.splitlines():
if "OK:" in line:
message = line.split("OK:", 1)[1].strip()
break
else:
for line in stdout.splitlines():
stripped = line.strip()
if stripped and "CHANGED" not in stripped and "WARNING" not in stripped:
message = stripped
break
except AnsibleError as e:
message = str(e)
results.append({"name": runner_name, "host": r_host, "healthy": healthy, "message": message})
return results
@staticmethod
def _parse_status(stdout: str) -> str:
ansible_noise = (" | CHANGED | ", " | FAILED | ", " | UNREACHABLE | ", "[WARNING]", "ssh:", ">>")
+144
View File
@@ -31,6 +31,14 @@
"ru": "Проверить состояние зарегистрированного Gitea Runner.",
"zh": "检查已注册的 Gitea Runner 状态。"
},
"Checking health of {name} on {host}": {
"bg": "Проверка на здравословното състояние на {name} на {host}",
"de": "Gesundheitsprüfung von {name} auf {host}",
"en": "Checking health of {name} on {host}",
"pl": "Sprawdzanie zdrowia {name} na {host}",
"ru": "Проверка здоровья {name} на {host}",
"zh": "正在检查 {host} 上 {name} 的健康状态"
},
"Checking status of Gitea Runner {name} on {host}": {
"bg": "Проверка на състоянието на Gitea Runner {name} на {host}",
"de": "Prüfe Status von Gitea Runner {name} auf {host}",
@@ -175,6 +183,22 @@
"ru": "Токен админ API Gitea для интеграционного теста (env: CI_GITEA_TOKEN)",
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: CI_GITEA_TOKEN"
},
"HEALTHY": {
"bg": "ЗДРАВ",
"de": "GESUND",
"en": "HEALTHY",
"pl": "ZDROWY",
"ru": "ЗДОРОВ",
"zh": "健康"
},
"Health check failed: {error}": {
"bg": "Проверката на здравословното състояние неуспешна: {error}",
"de": "Gesundheitsprüfung fehlgeschlagen: {error}",
"en": "Health check failed: {error}",
"pl": "Sprawdzanie zdrowia nie powiodło się: {error}",
"ru": "Проверка здоровья не удалась: {error}",
"zh": "健康检查失败: {error}"
},
"HOST": {
"bg": "ХОСТ",
"de": "HOST",
@@ -239,6 +263,14 @@
"ru": "Ошибка списка: {error}",
"zh": "列表失败: {error}"
},
"MESSAGE": {
"bg": "СЪОБЩЕНИЕ",
"de": "MELDUNG",
"en": "MESSAGE",
"pl": "WIADOMOŚĆ",
"ru": "СООБЩЕНИЕ",
"zh": "消息"
},
"NAME": {
"bg": "ИМЕ",
"de": "NAME",
@@ -263,6 +295,14 @@
"ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.",
"zh": "没有已注册的 runners。使用 'grm install' 添加一个。"
},
"One or more runners are unhealthy": {
"bg": "Един или повече runners са нездравословни",
"de": "Ein oder mehrere Runner sind fehlerhaft",
"en": "One or more runners are unhealthy",
"pl": "Jeden lub więcej runnerów jest w złym stanie",
"ru": "Один или несколько runners нездоровы",
"zh": "一个或多个 runners 不健康"
},
"Override SSH key from registry": {
"bg": "Замяна на SSH ключа от регистъра",
"de": "SSH-Schlüssel aus Registrierung überschreiben",
@@ -423,6 +463,14 @@
"ru": "Выполнение Ansible playbook",
"zh": "正在运行 Ansible playbook"
},
"Run health check on one or all registered runners.": {
"bg": "Проверка на здравословното състояние на един или всички регистрирани runners.",
"de": "Gesundheitsprüfung für einen oder alle registrierten Runner ausführen.",
"en": "Run health check on one or all registered runners.",
"pl": "Uruchom sprawdzanie zdrowia jednego lub wszystkich zarejestrowanych runnerów.",
"ru": "Проверить здоровье одного или всех зарегистрированных runners.",
"zh": "对一个或所有已注册 runners 运行健康检查。"
},
"SSH user (env: GITEA_RUNNER_USER)": {
"bg": "SSH потребител (env: GITEA_RUNNER_USER)",
"de": "SSH-Benutzer (env: GITEA_RUNNER_USER)",
@@ -630,5 +678,101 @@
"pl": "nieznany",
"ru": "неизвестно",
"zh": "未知"
},
"CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)": {
"bg": "CI_GITEA_TOKEN е задължителен (задайте --token или CI_GITEA_TOKEN env var)",
"de": "CI_GITEA_TOKEN ist erforderlich (setzen Sie --token oder CI_GITEA_TOKEN env var)",
"en": "CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)",
"pl": "CI_GITEA_TOKEN jest wymagany (ustaw --token lub CI_GITEA_TOKEN env var)",
"ru": "CI_GITEA_TOKEN обязателен (установите --token или CI_GITEA_TOKEN env var)",
"zh": "需要 CI_GITEA_TOKEN(设置 --token 或 CI_GITEA_TOKEN 环境变量)"
},
"GITEA_URL is required (set --url or GITEA_URL env var)": {
"bg": "GITEA_URL е задължителен (задайте --url или GITEA_URL env var)",
"de": "GITEA_URL ist erforderlich (setzen Sie --url oder GITEA_URL env var)",
"en": "GITEA_URL is required (set --url or GITEA_URL env var)",
"pl": "GITEA_URL jest wymagany (ustaw --url lub GITEA_URL env var)",
"ru": "GITEA_URL обязателен (установите --url или GITEA_URL env var)",
"zh": "需要 GITEA_URL(设置 --url 或 GITEA_URL 环境变量)"
},
"Git ref to run the workflow on (default: master)": {
"bg": "Git ref за изпълнение на работния процес (по подразбиране: master)",
"de": "Git-Ref für die Workflow-Ausführung (Standard: master)",
"en": "Git ref to run the workflow on (default: master)",
"pl": "Git ref do uruchomienia workflow (domyślnie: master)",
"ru": "Git ref для запуска workflow (по умолчанию: master)",
"zh": "运行工作流的 Git ref(默认:master"
},
"Gitea API token (env: CI_GITEA_TOKEN)": {
"bg": "Gitea API токен (env: CI_GITEA_TOKEN)",
"de": "Gitea API-Token (env: CI_GITEA_TOKEN)",
"en": "Gitea API token (env: CI_GITEA_TOKEN)",
"pl": "Token API Gitea (env: CI_GITEA_TOKEN)",
"ru": "Токен API Gitea (env: CI_GITEA_TOKEN)",
"zh": "Gitea API 令牌(环境变量:CI_GITEA_TOKEN"
},
"List available workflows instead of triggering one": {
"bg": "Списък на наличните работни процеси вместо изпълнение",
"de": "Verfügbare Workflows auflisten statt auszuführen",
"en": "List available workflows instead of triggering one",
"pl": "Wyświetl dostępne workflow zamiast uruchamiać",
"ru": "Список доступных workflow вместо запуска",
"zh": "列出可用工作流而不是触发"
},
"No workflows found in {repo}": {
"bg": "Няма намерени работни процеси в {repo}",
"de": "Keine Workflows in {repo} gefunden",
"en": "No workflows found in {repo}",
"pl": "Nie znaleziono workflow w {repo}",
"ru": "В {repo} не найдено workflow",
"zh": "在 {repo} 中未找到工作流"
},
"Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)": {
"bg": "Хранилище във формат owner/repo (env: GRM_REPO, по подразбиране: oblachno-oss/grm)",
"de": "Repository im owner/repo-Format (env: GRM_REPO, Standard: oblachno-oss/grm)",
"en": "Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)",
"pl": "Repozytorium w formacie owner/repo (env: GRM_REPO, domyślnie: oblachno-oss/grm)",
"ru": "Репозиторий в формате owner/repo (env: GRM_REPO, по умолчанию: oblachno-oss/grm)",
"zh": "仓库格式为 owner/repo(环境变量:GRM_REPO,默认:oblachno-oss/grm"
},
"Trigger a Gitea Actions workflow via the API.": {
"bg": "Стартиране на Gitea Actions работен процес чрез API.",
"de": "Einen Gitea Actions-Workflow über die API auslösen.",
"en": "Trigger a Gitea Actions workflow via the API.",
"pl": "Uruchom workflow Gitea Actions przez API.",
"ru": "Запустить workflow Gitea Actions через API.",
"zh": "通过 API 触发 Gitea Actions 工作流。"
},
"Triggering workflow {wf} on {repo}@{ref}...": {
"bg": "Стартиране на работен процес {wf} в {repo}@{ref}...",
"de": "Workflow {wf} auf {repo}@{ref} wird ausgelöst...",
"en": "Triggering workflow {wf} on {repo}@{ref}...",
"pl": "Uruchamianie workflow {wf} na {repo}@{ref}...",
"ru": "Запуск workflow {wf} на {repo}@{ref}...",
"zh": "正在触发工作流 {wf} 于 {repo}@{ref}..."
},
"WORKFLOW_ID is required unless --list is used": {
"bg": "WORKFLOW_ID е задължителен, освен ако не се използва --list",
"de": "WORKFLOW_ID ist erforderlich, es sei denn --list wird verwendet",
"en": "WORKFLOW_ID is required unless --list is used",
"pl": "WORKFLOW_ID jest wymagany, chyba że użyto --list",
"ru": "WORKFLOW_ID обязателен, если не используется --list",
"zh": "除非使用 --list,否则需要 WORKFLOW_ID"
},
"Workflow triggered successfully.": {
"bg": "Работният процес е стартиран успешно.",
"de": "Workflow erfolgreich ausgelöst.",
"en": "Workflow triggered successfully.",
"pl": "Workflow uruchomiony pomyślnie.",
"ru": "Workflow успешно запущен.",
"zh": "工作流触发成功。"
},
"Workflow triggered successfully. Run ID: {run_id}": {
"bg": "Работният процес е стартиран успешно. ID на изпълнение: {run_id}",
"de": "Workflow erfolgreich ausgelöst. Run-ID: {run_id}",
"en": "Workflow triggered successfully. Run ID: {run_id}",
"pl": "Workflow uruchomiony pomyślnie. ID uruchomienia: {run_id}",
"ru": "Workflow успешно запущен. ID запуска: {run_id}",
"zh": "工作流触发成功。运行 ID{run_id}"
}
}
-642
View File
@@ -1,642 +0,0 @@
"""Unit tests for api_clients module."""
import http
from unittest.mock import MagicMock, patch
import pytest
import requests
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
mock = MagicMock()
if json_data is not None:
mock.json.return_value = json_data
if raise_on_status:
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
return mock
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
"""Create an HTTPError with a proper response attached (for _parse_error)."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = {"message": message or str(status_code)}
err = requests.HTTPError(f"{status_code} {message}", response=resp)
return err
class TestParseError:
def test_json_parse_fallback(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
mock_response.json = MagicMock(side_effect=ValueError("not json"))
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
status, message = _parse_error(err)
assert status == http.HTTPStatus.BAD_GATEWAY
assert str(http.HTTPStatus.BAD_GATEWAY) in message
def test_no_response(self) -> None:
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
status, message = _parse_error(err)
assert status == 0
assert "connection failed" in message
class TestGiteaClient:
def test_init_sets_headers(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._base_url == "https://git.example.com"
assert client._session.headers["Authorization"] == "token tok"
assert client._session.headers["Content-Type"] == "application/json"
def test_url_constructs_path(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_url_strips_trailing_slash(self) -> None:
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_list_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
result = client.list_labels()
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_list_labels_raises_api_error(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
with pytest.raises(APIError):
client.list_labels()
def test_http_error_json_parse_fallback(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
# Make json() itself raise so the except block in _parse_error is hit
mock_response.json = MagicMock(side_effect=ValueError("not json"))
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
client._session.request = MagicMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
client.list_labels()
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
def test_create_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
assert result["name"] == "ready-to-merge"
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
)
def test_ensure_label_creates_when_not_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[])
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_ensure_label_returns_none_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
client.create_label = MagicMock()
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is None
client.create_label.assert_not_called()
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response(
[
{"id": 1, "branch_name": "master"},
{"id": 2, "branch_name": "develop"},
]
)
)
result = client.list_branch_protections()
assert len(result) == 2
assert result[0]["branch_name"] == "master"
def test_create_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
result = client.create_branch_protection(BRANCH_PROTECTION_CONFIG)
assert result["id"] == 3
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/branch_protections",
timeout=DEFAULT_TIMEOUT,
json=BRANCH_PROTECTION_CONFIG,
)
def test_update_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
resp = {"branch_name": "master", "required_approvals": 2}
client._session.request = MagicMock(return_value=_mock_response(resp))
update = {"required_approvals": 2}
result = client.update_branch_protection("master", update)
assert result["required_approvals"] == 2
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo/branch_protections/master",
timeout=DEFAULT_TIMEOUT,
json=update,
)
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[])
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 1
client.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
def test_ensure_branch_protection_updates_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}])
client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1})
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["required_approvals"] == 1
expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.merge_pr(1, "fix: bug")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/1/merge",
timeout=DEFAULT_TIMEOUT,
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
result = client.get_pr_labels(5)
assert result == [{"name": "ready-to-merge"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/5/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_get_commit_status(self) -> None:
"""Uses combined status endpoint (/status, not /statuses)."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
)
result = client.get_commit_status("abc123")
assert result == [{"context": "CI / quality", "status": "success"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/commits/abc123/status",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
result = client.get_pr(7)
assert result["number"] == 7
assert result["head"]["sha"] == "abc123"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
)
result = client.get_pr_files(7)
assert len(result) == 1
assert result[0]["filename"] == "src/main.py"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/files",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_commits(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
)
result = client.get_pr_commits(7)
assert len(result) == 1
assert result[0]["commit"]["message"] == "fix: bug"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/commits",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
result = client.get_pr_reviews(7)
assert len(result) == 1
assert result[0]["state"] == "APPROVED"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"}))
result = client.create_issue(title="bug", body="description", labels=[1])
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description", "labels": [1]},
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue_no_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"}))
result = client.create_issue(title="bug", body="description")
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description"},
timeout=DEFAULT_TIMEOUT,
)
def test_create_review_comment(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
result = client.create_review(7, event="COMMENT", body="Looks good")
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "COMMENT", "body": "Looks good"},
)
def test_create_review_approve_maps_to_approved(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
result = client.create_review(7, event="APPROVE", body="Good work")
assert result["id"] == 44
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "APPROVED", "body": "Good work"},
)
def test_create_review_with_inline_comments(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
)
def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
settings = {"default_delete_branch_after_merge": True}
result = client.update_repo_settings(settings)
assert result["default_delete_branch_after_merge"] is True
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo",
timeout=DEFAULT_TIMEOUT,
json=settings,
)
def test_create_release(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
client.create_release("v1.0.0")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/releases",
timeout=DEFAULT_TIMEOUT,
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
)
def test_get_release_by_tag_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
result = client.get_release_by_tag("v1.0.0")
assert result is not None
assert result["id"] == 1
def test_get_release_by_tag_not_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
mock_resp.status_code = 404
client._session.request = MagicMock(return_value=mock_resp)
result = client.get_release_by_tag("v9.9.9")
assert result is None
def test_create_release_idempotent_existing(self) -> None:
"""If release already exists, should return it without creating a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
client._session.request = MagicMock(return_value=existing_response)
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 42
# Should only call GET (check), not POST (create)
assert client._session.request.call_count == 1
assert client._session.request.call_args[0][0] == "GET"
def test_create_release_idempotent_new(self) -> None:
"""If release doesn't exist, should create it."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found_resp = MagicMock()
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
not_found_resp.status_code = 404
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 1
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
"""Should retry on 429 rate limit with exponential backoff."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
rate_limited = MagicMock()
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 3
assert mock_sleep.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""Should retry on 503 service unavailable."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
"""Should NOT retry on 404 — it's not a transient error."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
client._session.request = MagicMock(return_value=not_found)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 404
assert client._session.request.call_count == 1
mock_sleep.assert_not_called()
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""Should retry on connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent 503."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestVikunjaClient:
def test_init_sets_headers(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
assert client._base_url == "https://work.example.com"
assert client._session.headers["Authorization"] == "Bearer tok"
def test_list_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "identifier": "GRM-19", "project_id": VIKUNJA_PROJECT_ID}])
)
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks",
timeout=DEFAULT_TIMEOUT,
params={"per_page": DEFAULT_PER_PAGE},
)
def test_list_project_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "GRM-19"}]))
result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks",
timeout=DEFAULT_TIMEOUT,
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
)
def test_get_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 292, "identifier": "GRM-32", "title": "Some task"})
)
result = client.get_task(292)
assert result["identifier"] == "GRM-32"
assert result["title"] == "Some task"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/292",
timeout=DEFAULT_TIMEOUT,
)
def test_post_comment(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.post_comment(42, "<p>hi</p>")
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
json={"comment": "<p>hi</p>"},
)
def test_update_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.update_task(42, done=True)
client._session.request.assert_called_once_with(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"done": True},
)
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError):
client.list_tasks()
def test_http_error_no_response(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = err
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert "connection failed" in str(exc_info.value)
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should also retry on 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should retry on connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
assert _is_retryable(requests.ConnectionError("refused")) is True
def test_timeout_is_retryable(self) -> None:
assert _is_retryable(requests.Timeout("timed out")) is True
def test_429_is_retryable(self) -> None:
err = _mock_http_error(429, "rate limited")
assert _is_retryable(err) is True
def test_404_is_not_retryable(self) -> None:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
def test_generic_exception_is_not_retryable(self) -> None:
assert _is_retryable(ValueError("oops")) is False
+157
View File
@@ -852,6 +852,75 @@ class TestCLI:
assert result.exit_code != 0
assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_all_healthy(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = [
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
{"name": "r2", "host": "10.0.0.2", "healthy": "yes", "message": "runner healthy, disk at 50%"},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code == 0
assert "r1" in result.output
assert "r2" in result.output
assert "yes" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_with_unhealthy(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = [
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
{"name": "r2", "host": "10.0.0.2", "healthy": "no", "message": "Docker daemon down"},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code != 0
assert "unhealthy" in result.output.lower()
assert "r2" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_single_runner(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = [
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health", "r1"], input="secret\n")
assert result.exit_code == 0
assert "r1" in result.output
mock_manager.health.assert_called_once()
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_empty(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = []
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code == 0
assert "No runners registered" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError
mock_manager.health.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code != 0
assert "fail" in result.output
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty"))
@patch("gitea_runner_manager.cli.RunnerManager")
def test_default_user_fallback_on_getlogin_error(
@@ -918,3 +987,91 @@ class TestCLI:
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/ansible.txt"}, clear=True):
assert _get_become_password_file() == "/tmp/ansible.txt"
class TestTriggerWorkflow:
"""Tests for the trigger-workflow CLI command."""
def test_trigger_workflow_success(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.return_value = {
"id": 42,
"html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42",
}
result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"])
assert result.exit_code == 0
assert "42" in result.output
mock_client.dispatch_workflow.assert_called_once_with("oblachno-oss", "grm", "ci.yml", "master")
def test_trigger_workflow_no_url(self) -> None:
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"])
assert result.exit_code != 0
assert "GITEA_URL" in result.output
def test_trigger_workflow_no_token(self) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com"}, clear=True):
result = runner.invoke(cli, ["trigger-workflow", "ci.yml"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
def test_trigger_workflow_list(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.list_workflows.return_value = [
{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"},
{"id": 2, "name": "Post-merge", "path": "post-merge.yml", "state": "active"},
]
result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"])
assert result.exit_code == 0
assert "CI" in result.output
assert "Post-merge" in result.output
mock_client.list_workflows.assert_called_once_with("oblachno-oss", "grm")
def test_trigger_workflow_list_empty(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.list_workflows.return_value = []
result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"])
assert result.exit_code == 0
assert "No workflows" in result.output
def test_trigger_workflow_no_workflow_id(self) -> None:
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["trigger-workflow", "--token", "tok"])
assert result.exit_code != 0
assert "WORKFLOW_ID" in result.output
def test_trigger_workflow_api_error(self) -> None:
from gitea_runner_manager.gitea_client import GiteaAPIError
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.side_effect = GiteaAPIError(404, "workflow not found")
result = runner.invoke(cli, ["trigger-workflow", "nonexistent.yml", "--token", "tok"])
assert result.exit_code != 0
assert "404" in result.output
def test_trigger_workflow_custom_repo_and_ref(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.return_value = None
result = runner.invoke(
cli,
["trigger-workflow", "build.yml", "--repo", "myorg/myrepo", "--ref", "develop", "--token", "tok"],
)
assert result.exit_code == 0
mock_client.dispatch_workflow.assert_called_once_with("myorg", "myrepo", "build.yml", "develop")
-56
View File
@@ -1,56 +0,0 @@
"""Unit tests for config module constants."""
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
REPO_NAME,
REPO_OWNER,
TASK_ID_RE,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
class TestConfigConstants:
def test_api_urls(self) -> None:
assert "api/v1" in GITEA_API_URL
assert "api/v1" in VIKUNJA_API_URL
def test_project_ids(self) -> None:
assert VIKUNJA_PROJECT_ID == 6
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner_and_repo(self) -> None:
assert REPO_OWNER == "oblachno-oss"
assert REPO_NAME == "grm"
def test_task_id_re(self) -> None:
assert TASK_ID_RE.search("GRM-1")
assert TASK_ID_RE.search("GRM-123")
assert not TASK_ID_RE.search("GRM-")
assert not TASK_ID_RE.search("other text")
def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
def test_branch_protection_config(self) -> None:
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
assert BRANCH_PROTECTION_CONFIG["enable_push"] is True
assert BRANCH_PROTECTION_CONFIG["enable_push_whitelist"] is True
assert "emil" in BRANCH_PROTECTION_CONFIG["push_whitelist_usernames"]
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 0
contexts = BRANCH_PROTECTION_CONFIG["status_check_contexts"]
assert isinstance(contexts, list)
assert len(contexts) == 4
assert "CI / quality (pull_request)" in contexts
assert any("molecule-tests" in c for c in contexts)
+120
View File
@@ -0,0 +1,120 @@
"""Unit tests for gitea_client module."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from gitea_runner_manager.gitea_client import GiteaAPIError, GiteaWorkflowClient
class TestGiteaWorkflowClient:
def _client(self) -> GiteaWorkflowClient:
return GiteaWorkflowClient("https://git.example.com", "test-token")
def test_list_workflows(self) -> None:
client = self._client()
mock_response = {"workflows": [{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"}]}
with patch.object(client, "_request", return_value=mock_response) as mock_req:
result = client.list_workflows("oblachno-oss", "grm")
assert len(result) == 1
assert result[0]["name"] == "CI"
mock_req.assert_called_once_with("GET", "/repos/oblachno-oss/grm/actions/workflows")
def test_list_workflows_empty(self) -> None:
client = self._client()
with patch.object(client, "_request", return_value=None):
result = client.list_workflows("oblachno-oss", "grm")
assert result == []
def test_dispatch_workflow(self) -> None:
client = self._client()
mock_response = {"id": 42, "html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42"}
with patch.object(client, "_request", return_value=mock_response) as mock_req:
result = client.dispatch_workflow("oblachno-oss", "grm", "ci.yml", ref="master")
assert result is not None
assert result["id"] == 42
mock_req.assert_called_once_with(
"POST",
"/repos/oblachno-oss/grm/actions/workflows/ci.yml/dispatches?return_run_details=true",
{"ref": "master"},
)
def test_dispatch_workflow_with_inputs(self) -> None:
client = self._client()
with patch.object(client, "_request", return_value=None) as mock_req:
client.dispatch_workflow("oblachno-oss", "grm", "build.yml", ref="master", inputs={"env": "prod"})
mock_req.assert_called_once_with(
"POST",
"/repos/oblachno-oss/grm/actions/workflows/build.yml/dispatches?return_run_details=true",
{"ref": "master", "inputs": {"env": "prod"}},
)
def test_dispatch_workflow_api_error(self) -> None:
client = self._client()
with patch.object(client, "_request", side_effect=GiteaAPIError(404, "workflow not found")):
with pytest.raises(GiteaAPIError) as exc_info:
client.dispatch_workflow("oblachno-oss", "grm", "nonexistent.yml")
assert exc_info.value.status == 404
class TestGiteaWorkflowClientRequest:
"""Test the underlying _request method with mocked urllib."""
def test_request_success(self) -> None:
client = GiteaWorkflowClient("https://git.example.com/", "tok")
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = json.dumps({"ok": True}).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
result = client._request("GET", "/test")
assert result == {"ok": True}
mock_urlopen.assert_called_once()
def test_request_204_no_content(self) -> None:
client = GiteaWorkflowClient("https://git.example.com", "tok")
mock_resp = MagicMock()
mock_resp.status = 204
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
result = client._request("POST", "/test", {"ref": "master"})
assert result is None
def test_request_http_error(self) -> None:
import urllib.error
client = GiteaWorkflowClient("https://git.example.com", "tok")
err = urllib.error.HTTPError(
"https://git.example.com/api/v1/test",
404,
"Not Found",
{},
__import__("io").BytesIO(b'{"message": "resource not found"}'),
)
with patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(GiteaAPIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 404
assert "resource not found" in exc_info.value.message
def test_request_http_error_non_json(self) -> None:
import urllib.error
client = GiteaWorkflowClient("https://git.example.com", "tok")
err = urllib.error.HTTPError(
"https://git.example.com/api/v1/test",
500,
"Internal Server Error",
{},
__import__("io").BytesIO(b"plain text error"),
)
with patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(GiteaAPIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 500
assert "plain text error" in exc_info.value.message
+106
View File
@@ -625,6 +625,112 @@ class TestRunnerManager:
assert manager.list_runners(no_status=True) == []
class TestHealth:
"""Tests for the ``health`` method."""
def test_health_single_runner_healthy(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": "/key"}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 42%"
manager._executor = mock_executor
results = manager.health(name="r1")
assert len(results) == 1
assert results[0]["name"] == "r1"
assert results[0]["healthy"] == "yes"
assert "runner healthy" in results[0]["message"]
mock_executor.run_ad_hoc.assert_called_once()
def test_health_single_runner_unhealthy(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "CRITICAL: Docker daemon still down after restart"
manager._executor = mock_executor
results = manager.health(name="r1")
assert len(results) == 1
assert results[0]["healthy"] == "no"
assert "Docker daemon still down" in results[0]["message"]
def test_health_all_runners(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
"r2": {"host": "10.0.0.2", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.side_effect = [
"OK: runner healthy, disk at 42%",
"ERROR: gitea-runner service is inactive, restarting",
]
manager._executor = mock_executor
results = manager.health()
assert len(results) == 2
assert results[0]["healthy"] == "yes"
assert results[1]["healthy"] == "no"
def test_health_empty_registry(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {}
manager = RunnerManager(registry=mock_registry)
results = manager.health()
assert results == []
def test_health_ansible_error(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.side_effect = AnsibleError("ssh unreachable")
manager._executor = mock_executor
results = manager.health(name="r1")
assert len(results) == 1
assert results[0]["healthy"] == "no"
assert "ssh unreachable" in results[0]["message"]
def test_health_with_host_override(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 50%"
manager._executor = mock_executor
results = manager.health(name="r1", host="10.0.0.99", user="root")
assert len(results) == 1
assert results[0]["host"] == "10.0.0.99"
call_args = mock_executor.run_ad_hoc.call_args.args
assert call_args[0] == "10.0.0.99"
assert call_args[1] == "root"
def test_health_runner_not_found(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = None
manager = RunnerManager(registry=mock_registry)
with pytest.raises(AnsibleError, match="not found in registry"):
manager.health(name="nonexistent")
def test_health_passes_become_pass(self) -> None:
"""become_pass is forwarded to run_ad_hoc for sudo authentication."""
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 42%"
manager._executor = mock_executor
manager.health(name="r1", become_pass="s3cr3t")
call_kwargs = mock_executor.run_ad_hoc.call_args.kwargs
assert call_kwargs["become_pass"] == "s3cr3t"
class TestExtraVarsFile:
"""Tests for the ``_extra_vars_file`` context manager."""