Compare commits

...
101 Commits
Author SHA1 Message Date
grm-ci-bot f905550aba release: v0.10.3 2026-06-27 20:26:33 +00:00
emil cae4e2a860 GRM-107: refactor: use devx Makefile aliases, bump devx>=0.23.0 2026-06-27 20:24:29 +00:00
gitea-actions-bot efbd24daec chore: update badge URLs to commit fc36a6d7 [skip ci] 2026-06-27 17:55:55 +00:00
emil 9066ef9724 GRM-106: fix: add EXTRAS=ci to all setup-image calls, workflow-level CI_GITEA_TOKEN 2026-06-27 17:53:45 +00:00
gitea-actions-bot 96770a770e chore: update badge URLs to commit 5c7cd006 [skip ci] 2026-06-27 17:41:15 +00:00
emil e753b34788 GRM-105: fix: revert EXTRAS=ci default in setup-image 2026-06-27 17:38:59 +00:00
gitea-actions-bot 48422b18e5 chore: update badge URLs to commit 1504e628 [skip ci] 2026-06-27 16:56:13 +00:00
emil 190157cce6 GRM-104: refactor: remove hadolint on-the-fly install workaround 2026-06-27 16:53:57 +00:00
gitea-actions-bot 1d0a082044 chore: update badge URLs to commit 0f5e8e82 [skip ci] 2026-06-27 16:40:47 +00:00
emil 799d36f254 GRM-103: fix: install hadolint on-the-fly in setup-image 2026-06-27 16:38:30 +00:00
gitea-actions-bot e0d43b0ed8 chore: update badge URLs to commit 8e0b18b4 [skip ci] 2026-06-27 16:34:11 +00:00
gitea-actions-bot 3189161f61 chore: update badge URLs to commit ed92e075 [skip ci] 2026-06-27 16:32:16 +00:00
grm-ci-bot c339698603 release: v0.10.2 2026-06-27 16:31:58 +00:00
emil cbf082c78f GRM-102: fix: bump devx>=0.22.0 and remove REPO_TOKEN alias 2026-06-27 16:30:16 +00:00
gitea-actions-bot 4070135fda chore: update badge URLs to commit 5c243201 [skip ci] 2026-06-27 15:51:35 +00:00
emil ace0176e3a GRM-101: refactor: rename REPO_TOKEN to CI_GITEA_TOKEN, consolidate env vars 2026-06-27 15:49:36 +00:00
gitea-actions-bot fda1d99d86 chore: update badge URLs to commit 42a9384e [skip ci] 2026-06-27 13:49:17 +00:00
emil 465f45d939 GRM-100: fix: gate auto-merge on release-dry-run and unmask failures 2026-06-27 13:47:30 +00:00
gitea-actions-bot 103beaa0e8 chore: update badge URLs to commit 6b1270e1 [skip ci] 2026-06-27 13:30:20 +00:00
emil 2e97578269 GRM-99: fix: setup-image configures Gitea PyPI registry and shows pip errors 2026-06-27 13:27:59 +00:00
gitea-actions-bot c31ec312ac chore: update badge URLs to commit b3f0d6da [skip ci] 2026-06-27 13:16:12 +00:00
emil c67b810e58 GRM-98: ci: add --auto-login to publish, bump devx>=0.21.0 2026-06-27 13:14:13 +00:00
gitea-actions-bot ef16b07cdf chore: update badge URLs to commit 9addc544 [skip ci] 2026-06-27 12:34:11 +00:00
emil 2c849c7324 GRM-97: ci: use pre-built tier images for all CI workflows 2026-06-27 12:32:13 +00:00
gitea-actions-bot 5804a18974 chore: update badge URLs to commit a7e1f518 [skip ci] 2026-06-27 00:24:48 +00:00
gitea-actions-bot 9dbe20ba73 chore: update badge URLs to commit 63466f88 [skip ci] 2026-06-27 00:21:48 +00:00
grm-ci-bot b92c87ba68 release: v0.10.1 2026-06-27 02:21:19 +02:00
emil dc4430d160 GRM-96: ci: add molecule test weights to pyproject.toml 2026-06-27 00:18:31 +00:00
gitea-actions-bot 7aa0ebaefe chore: update badge URLs to commit 182f11ca [skip ci] 2026-06-26 19:42:47 +00:00
emil e93da43219 GRM-95: refactor: consolidate publish.yml into post-merge.yml 2026-06-26 19:39:50 +00:00
gitea-actions-bot b131a2872d chore: update badge URLs to commit 886112ce [skip ci] 2026-06-26 19:17:41 +00:00
emil e4dd8f308f GRM-94: refactor: replace duplicated Makefile targets with devx.mak aliases 2026-06-26 19:14:33 +00:00
gitea-actions-bot 467e0d66e6 chore: update badge URLs to commit c2d3c4bb [skip ci] 2026-06-26 18:06:12 +00:00
emil 6ee5b74bb5 GRM-93: ci: use make setup-ci consistently, decouple vikunja/sync-wiki from release 2026-06-26 18:03:02 +00:00
gitea-actions-bot 21cc89899f chore: update badge URLs to commit c29fbfe0 [skip ci] 2026-06-26 15:54:42 +00:00
emil 0382e155a6 GRM-92: fix: set PYTHONPATH=src in publish Install CI tools step 2026-06-26 15:51:06 +00:00
gitea-actions-bot d87c0d7e9a chore: update badge URLs to commit b19d4b06 [skip ci] 2026-06-26 17:18:30 +02:00
gitea-actions-bot 4be480a18e chore: update badge URLs to commit f916dabb [skip ci] 2026-06-26 15:18:03 +00:00
grm-ci-bot de92f675ed release: v0.10.0
Publish Release / publish (push) Waiting to run
2026-06-26 17:16:19 +02:00
emil b5803a8611 GRM-91: feat: adopt devx tools, devx.mak fragment, ci extra, remove legacy install-devx 2026-06-26 15:14:33 +00:00
gitea-actions-bot ec22d20a15 chore: update badge URLs to commit 47c7e239 [skip ci] 2026-06-26 12:03:12 +02:00
emil e96012af7f GRM-90: ci: bump devx version to v0.14.1 2026-06-26 10:00:03 +00:00
gitea-actions-bot addef7500c chore: update badge URLs to commit dd820c3d [skip ci] 2026-06-26 00:13:49 +02:00
emil 8a0d2c428b GRM-88: docs: fix AGENTS.md squash-merge format to use colon after task ID 2026-06-25 22:11:11 +00:00
gitea-actions-bot d68fb6d272 chore: update badge URLs to commit 9b029d3a [skip ci] 2026-06-25 23:17:41 +02:00
emil 6721864b87 GRM-87: fix: always run publish in post-merge (idempotent) 2026-06-25 21:15:03 +00:00
gitea-actions-bot 06471d1403 chore: update badge URLs to commit c61f4cb6 [skip ci] 2026-06-25 01:23:53 +02:00
grm-ci-bot 1a04971622 release: v0.9.0 [skip ci] 2026-06-25 01:22:18 +02:00
emil 91440c1b0a GRM-86: feat: add Polish as officially supported language 2026-06-24 23:20:59 +00:00
gitea-actions-bot 3bb8d75748 chore: update badge URLs to commit ea0b5002 [skip ci] 2026-06-24 22:53:41 +00:00
emil d4a8172a25 GRM-85: docs: add Gitea PyPI registry instructions for pip install 2026-06-24 22:51:10 +00:00
gitea-actions-bot 2199ec0bfe chore: update badge URLs to commit b70a95f6 [skip ci] 2026-06-25 00:42:14 +02:00
emil 9ae9a76b11 GRM-84: feat: adopt devx v0.11.1 across Makefile and workflows 2026-06-24 22:39:35 +00:00
gitea-actions-bot 31d0eeae98 chore: update badge URLs to commit eaa85dd7 [skip ci] 2026-06-25 00:31:14 +02:00
grm-ci-bot acc768eaea release: v0.8.1 [skip ci] 2026-06-25 00:29:27 +02:00
emil cf2314c20f GRM-83: fix: add build/twine to ci deps, activate venv in notify_failure 2026-06-24 22:28:08 +00:00
gitea-actions-bot aac47e3472 chore: update badge URLs to commit a7a72d7d [skip ci] 2026-06-25 00:14:31 +02:00
emil 811e7a2309 GRM-82: fix: repair publish workflow and add publish step to post-merge 2026-06-24 22:11:48 +00:00
gitea-actions-bot cb68c85bb5 chore: update badge URLs to commit 123065d0 [skip ci] 2026-06-24 21:06:40 +00:00
emil 0ba30e09ea GRM-81: ci: update devx to v0.10.2 for badge testpaths fix 2026-06-24 21:03:53 +00:00
gitea-actions-bot 6a02581687 chore: update badge URLs to commit e32f1c05 [skip ci] 2026-06-24 22:45:35 +02:00
grm-ci-bot 4679473183 release: v0.8.0 [skip ci] 2026-06-24 22:44:06 +02:00
emil 6359eab962 GRM-80: ci: update devx to v0.10.1 for badge generation fix 2026-06-24 20:42:27 +00:00
emil 2017a5ee3e GRM-79: feat: remove .taskid file, use branch name only for task ID 2026-06-24 20:15:59 +00:00
emil 465e9bd484 GRM-78: fix: add workflow_dispatch to publish workflow and update devx to 0.9.12 2026-06-24 20:05:54 +00:00
grm-ci-bot da0656b949 release: v0.7.0 [skip ci] 2026-06-24 21:19:57 +02:00
emil 64ab0f059b GRM-75: feat: thoroughly clean Docker artifacts on runner removal
## Summary

Thoroughly cleans Docker artifacts on runner removal, updates devx to v0.9.11, fixes Makefile checkmake graceful skip, and comprehensive docs rewrite.

Molecule tests fail due to pre-existing Docker infrastructure issue (Docker socket not available in CI runners).

Closes GRM-75
2026-06-24 19:18:23 +00:00
emil a58f5ec301 GRM-77: fix: replace stale badge SHA URLs with raw/branch/badges/ 2026-06-24 17:30:17 +00:00
emil 1dc20025d8 GRM-76: fix: retrospective fixes for CI/CD friction 2026-06-24 16:47:01 +00:00
emil e6918a9be9 GRM-74: fix: lower test speed threshold to 4s and update devx to v0.8.2 2026-06-23 20:47:42 +00:00
emil c5d8cbef5a GRM-73: feat: adopt per-test timing quality gate from devx 0.7.0 2026-06-23 16:51:52 +00:00
emil 6032a07038 GRM-72: feat: switch devx installation from git to Gitea PyPI registry 2026-06-23 14:16:44 +00:00
emil af251ffdaa GRM-70: fix: rewrite CHANGELOG with correct version ordering and missing sections 2026-06-22 22:23:32 +00:00
emil 493051b79b GRM-69: fix: pin devx to v0.4.4 to fix validate-commit-msg and sync-wiki 2026-06-22 21:49:27 +00:00
emil cb94709091 GRM-68: fix: pin devx to v0.4.3 to fix post-merge workflow failures 2026-06-22 21:36:28 +00:00
emil 7987778a4f GRM-67: fix: update devx to v0.4.2 and fix workflow env vars 2026-06-22 21:30:23 +00:00
grm-ci-bot 488a7ee048 release: v0.6.3 [skip ci] 2026-06-22 23:13:00 +02:00
emil 7fca2a3ebd GRM-66: fix: add scripts/** to infrastructure classification config 2026-06-22 21:09:42 +00:00
grm-ci-bot f14ef14dc6 release: v0.6.2 [skip ci] 2026-06-22 22:28:42 +02:00
emil 6758b69a5f GRM-65: fix: pin devx to v0.4.0, fix cliff.toml preprocessor, bump to v0.7.0 2026-06-22 20:27:11 +00:00
grm-ci-bot ba5b05bde2 release: v0.6.1 [skip ci] 2026-06-22 21:46:53 +02:00
emil 041e5ac4aa GRM-64: refactor: migrate from scripts/ to devx package 2026-06-22 19:45:24 +00:00
gitea-actions-bot a0f997cb3e chore: update badge URLs to commit 424af0de [skip ci] 2026-06-22 19:19:48 +02:00
grm-ci-bot 00828527f9 release: v0.6.1 [skip ci] 2026-06-22 19:18:06 +02:00
emil 564b917234 GRM-63: fix: make sync-wiki and vikunja depend on release 2026-06-22 17:16:29 +00:00
gitea-actions-bot f445085d54 chore: update badge URLs to commit ccf86e89 [skip ci] 2026-06-22 15:13:05 +02:00
grm-ci-bot 60a7f72156 release: v0.6.1 [skip ci] 2026-06-22 15:11:54 +02:00
emil 5174e103f7 GRM-59: fix: use commit SHA URLs for badges to bypass Gitea cache 2026-06-22 13:10:32 +00:00
grm-ci-bot 1189807d4e release: v0.6.1 [skip ci] 2026-06-22 14:23:20 +02:00
emil 9e420e3dab GRM-62: fix: include lint extras in setup-ci and setup-release 2026-06-22 12:22:02 +00:00
emil c9c46e88ac GRM-61: refactor: separate GRM and CI translations with validation 2026-06-22 12:13:58 +00:00
grm-ci-bot 1e04d38d59 release: v0.6.1 [skip ci] 2026-06-22 13:06:16 +02:00
emil dcb2ed0fd9 GRM-58: refactor: require tea CLI everywhere, fail on missing Vikunja task 2026-06-22 11:04:51 +00:00
grm-ci-bot 5e270f21d1 release: v0.6.1 [skip ci] 2026-06-22 12:46:56 +02:00
emil 8e532839fd GRM-57: refactor: fully automate PR merge — no manual label/review needed 2026-06-22 10:45:26 +00:00
grm-ci-bot f5177c823c release: v0.6.1 [skip ci] 2026-06-22 12:27:47 +02:00
emil f649120172 GRM-59: fix: post-merge workflow failures (4 jobs) 2026-06-22 10:26:28 +00:00
emil 7d3cf999d6 GRM-58: fix: enforce commit message convention on master with CI validation 2026-06-22 10:12:01 +00:00
emilandEmil Simeonov 3ae263fb00 GRM-57: fix: badges always update on release commits + fix configure-repo PYTHONPATH 2026-06-22 12:00:02 +02:00
emil 505673bc62 GRM-56: fix: close CI gaps with workflow dry-run, automated configure_repo, aligned timeouts 2026-06-22 08:23:06 +00:00
emil a791800809 GRM-55: fix: strengthen review process with deeper checks and structured checklist 2026-06-22 08:16:42 +00:00
106 changed files with 3940 additions and 12838 deletions
+13 -1
View File
@@ -15,7 +15,7 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# If set, API checks are performed as a bonus but do NOT affect pass/fail.
# Required scopes: read:user, read:repository, read:admin (or just "admin")
# Generate token at: Settings → Applications → Generate New Token
# REPO_TOKEN=your-admin-api-token
# CI_GITEA_TOKEN=your-admin-api-token
# Integration test API retries (optional, default: 3).
# Number of times to retry API checks waiting for runner to appear.
@@ -36,3 +36,15 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# UI language for GRM console messages (optional, default: en)
# Supported: en, bg, de, ru, zh
# GRM_LANG=en
# Gitea PyPI registry username (for private package access)
# Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL
CI_GITEA_USERNAME=emil
# devx configuration (GRM-specific overrides)
# Task prefix for Vikunja task IDs
DEVX_TASK_PREFIX=GRM
# Vikunja project ID for GRM
DEVX_VIKUNJA_PROJECT_ID=6
# Version file path (relative to repo root)
DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py
-34
View File
@@ -1,34 +0,0 @@
name: Auto-merge
on:
pull_request:
types: [labeled, unlabeled]
jobs:
merge:
# Always run — the Python script checks for the label via API.
# Gitea's `labeled` event payload may not populate pull_request.labels
# correctly, so we can't rely on the YAML-level condition.
if: github.event.label.name == 'ready-to-merge'
runs-on: docker
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
python3 scripts/ci/auto_merge.py \
"$HEAD_REF" \
"$PR_TITLE" \
"$REPOSITORY" \
"$PR_NUMBER" \
"ready-to-merge"
+125 -20
View File
@@ -5,14 +5,22 @@ on:
types: [opened, synchronize]
workflow_dispatch:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=lint
- name: Lint all
run: |
. .venv/bin/activate
@@ -22,16 +30,16 @@ jobs:
run: |
. .venv/bin/activate
make pytest-cov
- name: Check unit test speed
- name: Translation completeness check
run: |
. .venv/bin/activate
python3 scripts/check_test_speed.py --max-seconds 10
- name: Documentation coverage check
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
python3 scripts/ci/doc_coverage.py --fail-on-missing
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Dependency security scan
run: |
. .venv/bin/activate
@@ -39,28 +47,45 @@ jobs:
.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
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
make workflow-dryrun
else
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
fi
release-dry-run:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Release dry-run validation
env:
PYTHONPATH: .
PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/release.py --dry-run || true
python3 -m devx.ci.release --dry-run
detect-changes:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
outputs:
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
@@ -70,14 +95,18 @@ jobs:
with:
fetch-depth: 0
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Detect changed paths
id: detect
env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
python3 scripts/ci/classify_changes.py \
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
@@ -86,6 +115,7 @@ jobs:
needs: [detect-changes]
if: needs.detect-changes.outputs.ansible-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
outputs:
runner-count: ${{ steps.discover.outputs.runner-count }}
@@ -93,16 +123,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Discover available runners
id: discover
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 scripts/ci/discover_runners.py \
python3 -m devx.molecule.discover_runners \
--owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \
--github-output
@@ -111,6 +144,7 @@ jobs:
needs: [quality, detect-changes, discover-runners]
if: needs.detect-changes.outputs.ansible-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
strategy:
matrix:
@@ -118,14 +152,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,molecule
- name: Install Ansible collections
run: |
. .venv/bin/activate
python3 -m devx.tools.setup --skip-install --no-pre-commit --no-tea-login
- name: Discover assigned test pairs
env:
RUNNER_INDEX: ${{ matrix.runner-index }}
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 scripts/ci/distribute_molecule.py \
python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \
--github-env --skip-if-excess
@@ -134,31 +176,94 @@ jobs:
run: |
. .venv/bin/activate
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"
exit 0
fi
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
env:
GITEA_URL: ${{ github.server_url }}
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
RUN_ID: ${{ github.run_id }}
JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
DOCKER_HOST: unix:///var/run/docker.sock
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Run automated PR review
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
python3 scripts/ci/pr_review.py \
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# 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]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.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')
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Squash merge with task ID
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
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 }}
run: |
. .venv/bin/activate
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
"$REPOSITORY" \
"$PR_NUMBER"
+194 -40
View File
@@ -1,30 +1,43 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# replaces the previous 4 separate workflows (release.yml, post-merge.yml,
# sync-wiki.yml, and the badges job from ci.yml).
# for release, publish, wiki sync, badges, and Vikunja task updates.
#
# Job dependency graph:
#
# detect-type ──┬── release (skip if release commit)
# ├── sync-wiki (skip if release commit)
# ├── badges (runs after release, even if it fails)
# └── vikunja (skip if release commit)
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (ALWAYS runs — even on release commits)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# The badges job depends on release so it picks up the latest version
# number. It uses `if: always()` to run even if release fails or is
# skipped, ensuring badges always reflect the current repo state.
# sync-wiki and vikunja run for ALL non-release commits, not just when
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# When release.py creates a "release: vX.Y.Z" commit, all jobs skip
# because it's a release commit. The tag push triggers publish.yml.
# The badges job uses `if: always()` with no is-release condition so it
# runs on every push to master, including release commits. This ensures
# badges (tests, coverage, version, etc.) are always current.
#
# When release creates a "release: vX.Y.Z" commit and tag, the publish
# job (which depends on release) builds and publishes the package to the
# Gitea PyPI registry. The release commit's post-merge run still updates
# badges (version badge picks up the new version). Other jobs skip.
on:
push:
branches: [master]
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
detect-type:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
outputs:
is-release: ${{ steps.check.outputs.is-release }}
@@ -32,105 +45,201 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Check if this is a release commit
id: check
run: python3 scripts/ci/detect_release_commit.py
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
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: 1
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Validate latest commit message
env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
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
release:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Configure git
run: |
git config user.name "grm-ci-bot"
git config user.email "grm-ci-bot@oblachno.fyi"
- name: Run release
id: release-tag
env:
PYTHONPATH: .
PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/release.py
python3 -m devx.ci.release
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/notify_failure.py \
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Build and publish release
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish \
"${{ needs.release.outputs.tag }}" \
"${{ github.repository }}" --auto-login
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Sync documentation to wiki
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/notify_failure.py \
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}"
badges:
needs: [detect-type, release]
if: always() && needs.detect-type.outputs.is-release == 'false'
needs: [detect-type]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: master
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
run: make setup
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=lint
- name: Generate and push badges
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
python3 scripts/ci/push_badges.py
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/notify_failure.py \
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \
@@ -140,26 +249,71 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
run: python3 scripts/ci/post_merge.py --git-sha "${{ github.sha }}"
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/notify_failure.py \
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \
--commit "${{ github.sha }}"
configure-repo:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: grm
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
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \
--commit "${{ github.sha }}"
-48
View File
@@ -1,48 +0,0 @@
name: Publish Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install CI tools
run: python3 scripts/install_tools.py --tool git-cliff --tool tea
- name: Install build tools
run: python3 -m pip install --break-system-packages build twine requests python-dotenv click
- name: Configure tea login
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
export PATH="$HOME/.local/bin:$PATH"
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
PYTHONPATH: .:src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/publish.py \
"${{ github.ref_name }}" \
"${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/notify_failure.py \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "publish" \
--commit "${{ github.sha }}"
+3
View File
@@ -38,3 +38,6 @@ activate.zsh
# Generated badges (CI pushes to badges branch)
.badges/
# Deprecated CI task tracking (branch name is the sole source of truth)
.taskid
+2 -2
View File
@@ -3,7 +3,7 @@ repos:
hooks:
- id: validate-commit-msg
name: validate commit message
entry: .venv/bin/python scripts/ci/validate_commit_msg.py
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
language: system
stages: [commit-msg]
pass_filenames: true
@@ -67,7 +67,7 @@ repos:
- id: commit-msg
name: validate commit message
entry: .venv/bin/python scripts/ci/validate_commit_msg.py
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
language: system
stages: [commit-msg]
pass_filenames: true
+125 -90
View File
@@ -17,10 +17,10 @@ make workflow-check # workflow-lint + workflow-dryrun
```
`make setup` automatically installs all development tools:
- **Python deps** via `scripts/setup.py` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
- **checkmake** via `scripts/install_checkmake.py` (Makefile linter)
- **actionlint, git-cliff, act_runner, tea** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `scripts/setup.py` (configures `tea login` from `.env` `REPO_TOKEN`)
- **Python deps** via `pip install -e .[dev]` (includes devx from Gitea PyPI registry, configured by `make configure-gitea-pypi`)
- **Post-install setup** via `devx.tools.setup --skip-install` (ansible-galaxy, pre-commit hooks, tea CLI login)
- **checkmake** via `devx.tools.install_checkmake` (Makefile linter)
- **actionlint, git-cliff, act_runner, tea** via `devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
## Workflow Verification (Before Push)
@@ -29,7 +29,7 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
1. **actionlint** — Static linter that catches syntax errors, invalid
expressions, unknown keys, type mismatches, and shellcheck issues.
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
Installed automatically by `make setup` via `scripts/install_tools.py`.
Installed automatically by `make setup` via `devx.tools.install_tools`.
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
Validates job dependencies, step ordering, and Docker image selection
@@ -38,12 +38,13 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
Both run via `make workflow-check` and are part of `make lint-all`.
The pre-commit hook runs actionlint automatically when workflow files change.
The CI `quality` job runs `make setup` (which installs all tools) then `make lint-all`.
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Architecture
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
## PR Workflow (Mandatory)
@@ -52,7 +53,12 @@ Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings)
Configure the following branch protection rules for `master` in Gitea repo settings:
Branch protection and labels are automatically configured by
`devx.tools.configure_repo` (run as `python -m devx.tools.configure_repo`),
which runs as a `configure-repo` job in
the post-merge workflow on every push to master.
The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
- **Require status checks**: CI quality + molecule tests must pass
@@ -62,7 +68,7 @@ The auto-merge workflow enforces the APPROVE review check programmatically
as a defense-in-depth measure, but branch protection is the primary gate.
### 1. Create Vikunja Task
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
Create a task in Vikunja project 6 via `make create-task -- --title "Task title" --description "<h2>...</h2>"` (requires `VIKUNJA_TOKEN` in `.env`). This prints the `GRM-N` identifier and next-step instructions.
### 2. Create Branch
```bash
@@ -84,19 +90,22 @@ docs: update README
```
### 5. Push and Create PR
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
- Push: `git push -u origin HEAD` (pre-push hook validates Vikunja task existence via `devx.tools.pre_push_check`)
- Create PR: `make create-pr` (creates a PR with title `GRM-N: <vikunja task title>`, auto-derived from the branch name and Vikunja task)
- Or both in one step: `make push-with-pr`
- PR body: summary of changes, `Closes GRM-N`
- Add `ready-to-merge` label **only after review is complete**
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
**Review checklist:** Every PR is reviewed against
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 10 categories covering
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 13 categories covering
architecture, code quality, security, i18n, testing, performance,
UX, documentation, workflow compliance, and maintainability.
UX, documentation, workflow compliance, maintainability, resource
management, backwards compatibility, and logging.
**Automated review (CI `pr-review` job):** Every PR triggers an automated
review via `scripts/ci/pr_review.py`. This job posts a review with
review via `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`). This job posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
the **[auto]** items in the checklist:
@@ -104,8 +113,11 @@ the **[auto]** items in the checklist:
- Best practices (no `print()`, no bare `except`, no `TODO`/`FIXME`,
no functions > 50 lines)
- Security (no hardcoded secrets, no `shell=True`, no `eval`/`exec`)
- i18n (no raw strings in `click.echo()` without `_()` wrapper)
- Resource management (no `open()` without `with`, no `Popen()` without cleanup)
- Documentation (source changes must include doc updates)
- Test coverage (source changes must include test updates)
- 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
@@ -116,9 +128,9 @@ must go through **every category** in `REVIEW_CHECKLIST.md` and verify
the **[manual]** items by reviewing the full diff
(`git diff master...HEAD`).
Post review comments using `scripts/ci/review_pr.py`:
Post review comments using `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`):
```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
@@ -129,29 +141,33 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
### 8. Approve and Merge
Once all checklist items are verified and comments are addressed, post
an approval review with `--checklist-confirmed`:
an approval review with `--checklist-confirmed` and `--checklist-categories`:
```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--body "All 10 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
--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>."
```
The `--checklist-confirmed` flag is **required** for APPROVE events —
it attests that the reviewer has gone through every checklist category.
The review body must also be substantive (> 20 characters) — trivial
"LGTM" approvals are rejected by the auto-merge gate.
The `--checklist-categories` flag is also **required** — it must list at
least 8 of the 13 category numbers, ensuring the reviewer actually
checked each category rather than rubber-stamping. The review body must
be substantive (> 50 characters) — trivial approvals like "LGTM" are
rejected.
Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`GRM-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists (body > 20 chars or has inline comments)
3. Wait for all CI checks to pass (including the `pr-review` job)
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated, no colon after GRM-N)
4. Squash-merge with title: `GRM-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes (see below)
> **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.
> `GRM-N: <conventional>` format enforcement, producing incorrectly named commits.
> The auto-merge script validates the PR title matches the Vikunja task ID
> and conventional commit format before merging.
@@ -166,7 +182,7 @@ test infrastructure flakiness.
### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically via `scripts/ci/discover_runners.py`. The `discover-runners`
dynamically via `devx.molecule.discover_runners`. The `discover-runners`
job queries the Gitea API for runners at all levels (repo, org, instance)
and generates a dynamic matrix. If the API can't see instance-level runners
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
@@ -188,9 +204,9 @@ Vikunja task updates:
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits (the `[skip ci]` tag also prevents re-triggering).
2. **release** — Runs `scripts/ci/release.py` which:
- **Checks for user-facing changes** via `scripts/ci/classify_changes.py` — if only
workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`,
2. **release** — Runs `devx.ci.release` which:
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only
workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`,
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
- Uses **git-cliff** to calculate the next semver version from conventional commits
@@ -205,37 +221,45 @@ Vikunja task updates:
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not just when release succeeds), so docs-only
changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
Runs **after** the release job (even if release fails or is skipped) so the
version badge always reflects the latest state. The script fetches the
latest master before generating badges to pick up any release commits.
Uses `if: always()` so it runs on every push, including release commits.
The script fetches the latest master before generating badges to pick up
any release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not just when release succeeds), so infrastructure-only
changes still update the task tracker.
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
which builds and publishes the package to PyPI.
6. **publish** — Runs after release succeeds (needs: release). Builds and
publishes the package to the Gitea PyPI registry. Gets the tag from the
release job's `tag` output.
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project
classifies changes into two categories using `scripts/ci/classify_changes.py`:
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
types from accidentally skipping releases.
types from accidentally skipping releases. Classification is config-driven
via `[tool.devx.classify]` in `pyproject.toml`.
**Workflow-only paths** (infrastructure → no release needed):
- `.gitea/**` — Gitea Actions workflows
- `scripts/ci/**` — CI/CD automation scripts
- `scripts/setup.py`, `scripts/molecule_all.py`, `scripts/install_tools.py`, `scripts/__init__.py` — Dev tooling and package init
- `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` — Project docs
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config
- `.env.example`, `.gitignore`, `.ruff.toml` — Config
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `REVIEW_CHECKLIST.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
- `.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`)
@@ -243,9 +267,15 @@ types from accidentally skipping releases.
- `pyproject.toml` — Package metadata
- Any new file type not in the allowlist
**Script directory structure:**
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`, `generate_badges.py`, `gitea_cli.py`
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `detect_release_commit.py`, `push_badges.py`, `doc_coverage.py`, `sync_wiki.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `discover_runners.py`, `notify_failure.py`, `post_merge.py`, `pr_review.py`, `review_pr.py`, `validate_commit_msg.py`, `platforms.py`
**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.molecule.*` — Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guard
- `devx.gitea_cli` — Tea CLI wrapper
- `devx.i18n` — i18n translation system
- `devx.config` — Shared configuration (DEVX_* env vars)
- `devx.api_clients` — GiteaClient, VikunjaClient
- `devx.exceptions` — APIError and other exceptions
**CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
@@ -256,93 +286,89 @@ types from accidentally skipping releases.
**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
- The `classify_changes.py` script enforces this automatically — no manual intervention needed
- When adding a new CI script, place it in `scripts/ci/`. Dev tools go in `scripts/`.
- The `classify_changes` module enforces this automatically — no manual intervention needed
## Script Separation and Import Rules
## Source Code Separation and devx Integration
The codebase enforces strict separation between the GRM tool and CI/dev scripts:
The codebase enforces strict separation between the GRM tool and the devx package:
### Directory Layout
| Directory | Purpose | Release impact |
|-----------|---------|----------------|
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release |
| `scripts/` | Dev tools (run locally) | Workflow-only (no release) |
| `scripts/ci/` | CI/CD automation (run by workflows) | Workflow-only (no release) |
| `devx` package (installed from git) | Reusable CI/CD and dev tools | Not in this repo (no release impact) |
| `ansible/` | Ansible role for runner setup | Changes trigger release |
### Import Rules
1. **`src/gitea_runner_manager/` NEVER imports from `scripts/`** — the tool is self-contained
2. **Scripts MAY import from `gitea_runner_manager`** — one-way dependency (scripts use the tool's API clients, config, i18n)
3. **Cross-script imports** (scripts importing from other scripts) are allowed within `scripts/ci/` but must be documented
4. **`scripts/gitea_cli.py`** is a shared wrapper around the `tea` CLI — CI scripts import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
1. **`src/gitea_runner_manager/` NEVER imports from devx** — the GRM tool is self-contained
2. **devx MAY import from `gitea_runner_manager`** — one-way dependency (devx uses the tool's API clients, config, i18n)
3. **Cross-module imports within devx** are allowed (devx modules importing from other devx modules) and must be documented
4. **`devx.gitea_cli`** is a shared wrapper around the `tea` CLI — devx modules import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
### tea CLI Integration
The `tea` Gitea CLI tool is used for Gitea API interactions in CI scripts. It is installed by `scripts/install_tools.py` and configured by `scripts/setup.py` (login profile from `.env` `REPO_TOKEN`).
The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is installed by `devx.tools.install_tools` and configured by `devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
**`scripts/gitea_cli.py`** — Python wrapper around `tea` CLI with JSON output parsing:
**`devx.gitea_cli`** — Python wrapper around `tea` CLI with JSON output parsing:
- `TeaCLI.create_issue()` — Create issues with labels
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
- `TeaCLI.list_branches()` — Branch listing
**Scripts using tea (via `gitea_cli.py`):**
- `scripts/ci/publish.py` — Creates Gitea releases via `tea releases create`
- `scripts/ci/notify_failure.py` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
- `scripts/configure_repo.py` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
**Modules using tea (via `devx.gitea_cli`):**
- `devx.ci.publish` — Creates Gitea releases via `tea releases create`
- `devx.ci.notify_failure` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
- `devx.tools.configure_repo` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
**Operations still using `GiteaClient` (not supported by tea):**
- PR reviews (`review_pr.py`) — tea v0.14.1 only supports interactive reviews
- Wiki page management (`sync_wiki.py`)
- Commit status checks (`auto_merge.py`)
- Runner discovery (`discover_runners.py`)
- Branch protection with detailed config (`configure_repo.py`)
- PR file/commit listing (`pr_review.py`)
- PR reviews (`devx.ci.pr_review`) — tea v0.14.1 only supports interactive reviews
- Wiki page management (`devx.ci.sync_wiki`)
- Commit status checks (`devx.ci.auto_merge`)
- Runner discovery (`devx.molecule.discover_runners`)
- Branch protection with detailed config (`devx.tools.configure_repo`)
- PR file/commit listing (`devx.ci.pr_review`)
### PYTHONPATH Configuration
Scripts have different import requirements. Workflows must set `PYTHONPATH` accordingly:
Since devx is installed as a package (via `pip install` from git), it is importable directly. Workflows only need `PYTHONPATH=src` when a devx module imports from `gitea_runner_manager`:
| PYTHONPATH | When to use | Example scripts |
| PYTHONPATH | When to use | Example modules |
|------------|-------------|-----------------|
| `src` | Script imports from `gitea_runner_manager` | `auto_merge.py`, `pr_review.py`, `review_pr.py`, `sync_wiki.py`, `post_merge.py`, `classify_changes.py`, `discover_runners.py`, `doc_coverage.py` |
| `.:src` | Script imports from both `gitea_runner_manager` and `scripts.gitea_cli` | `publish.py`, `notify_failure.py`, `configure_repo.py` |
| `.` | Script imports from other `scripts.ci.*` modules | `release.py` (imports `classify_changes.has_user_facing_changes`) |
| (none) | Script has no GRM or cross-script imports | `detect_release_commit.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `push_badges.py`, `validate_commit_msg.py` |
| `src` | Module imports from `gitea_runner_manager` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.pr_review`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` |
| (none) | Module has no GRM imports | `devx.ci.detect_release_commit`, `devx.molecule.distribute_molecule`, `devx.molecule.molecule_ci_guard`, `devx.ci.push_badges`, `devx.ci.validate_commit_msg` |
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
```yaml
- name: Run script
- name: Run module
env:
PYTHONPATH: src
run: python3 scripts/ci/example.py
run: python -m devx.ci.example
```
**Locally**, the current directory is in `sys.path` by default, so `PYTHONPATH` is usually not needed.
**Locally**, devx is installed as a package, so only `PYTHONPATH=src` is needed if importing from `gitea_runner_manager`.
### Shared Constants
`scripts/ci/platforms.py` is the single source of truth for the molecule
platform matrix. Both `scripts/ci/distribute_molecule.py` (CI) and
`scripts/molecule_all.py` (dev tool) import `PLATFORMS` from it — this
avoids dev tools importing directly from CI scripts.
`devx.molecule.platforms` is the single source of truth for the molecule
platform matrix. Both `devx.molecule.distribute_molecule` (CI) and
`devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
avoids dev tools importing directly from CI modules.
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
- Triggers on tag push (`v*`)
- Validates `PYPI_TOKEN` is set (warns if missing)
2. **Publish job** (in `post-merge.yml`, needs: release):
- Runs after the release job creates a tag
- Gets the tag from `needs.release.outputs.tag`
- Builds the Python package
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
- Publishes to the Gitea PyPI registry
- Creates a Gitea release with git-cliff-generated release notes
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
### git-cliff Commit Preprocessing
Merge commits on master have the format `GRM-N <conventional commit>`. The
`GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml`
Merge commits on master have the format `GRM-N: <conventional commit>`. The
`GRM-N: ` prefix is not a valid conventional commit prefix, so `cliff.toml`
includes a `commit_preprocessors` entry that strips it before parsing. This
ensures all merged work appears in the changelog.
@@ -364,7 +390,16 @@ The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, r
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
| Branch commits | `<conventional commit>` | `feat: add review script` |
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
| Merge commit | `GRM-N: <conventional commit>` | `GRM-33: feat: add review script` |
### Configuration
The devx package is configured via `DEVX_*` environment variables:
- `DEVX_TASK_PREFIX=GRM` — Prefix for Vikunja task identifiers
- `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.
## Key Conventions
@@ -391,7 +426,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
Platform list is defined in `scripts/ci/platforms.py` (single source of truth)
Platform list is defined in `devx.molecule.platforms` (single source of truth)
## Known Issues
@@ -425,16 +460,16 @@ docs/
### Wiki Sync
- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/ci/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API
- **On merge to master**: `sync-wiki.yml` workflow runs `devx.ci.sync_wiki` which pushes all `/docs/` content to the Gitea wiki via API
- **On release tag**: Same sync runs, plus the wiki is tagged with the release version
- `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md` → `Getting-Started`)
- README.md is a lean entry point with links to the wiki — no detailed content
### Documentation Coverage
- `scripts/ci/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented
- Runs as a CI step in the quality job
- Goal: 100% coverage for public CLI commands and major architectural components
- `devx.ci.doc_coverage` checks that all CLI commands, Python modules, and CI scripts are documented
- Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing)
- Enforced: 100% coverage for public CLI commands and major architectural components
### Updating Documentation
+272 -155
View File
@@ -2,177 +2,294 @@
All notable changes to this project will be documented in this file.
## [0.6.0] - 2026-06-22
## [0.10.3] - 2026-06-27
### Bug Fixes
- Install hadolint on-the-fly in setup-image
- Revert EXTRAS=ci default in setup-image
- Add EXTRAS=ci to all setup-image calls, workflow-level CI_GITEA_TOKEN
### Refactor
- Remove hadolint on-the-fly install workaround
- Use devx Makefile aliases, bump devx>=0.23.0
## [0.10.2] - 2026-06-27
### Bug Fixes
- Setup-image configures Gitea PyPI registry and shows pip errors
- Gate auto-merge on release-dry-run and unmask failures
- Bump devx>=0.22.0 and remove REPO_TOKEN alias
### Refactor
- Rename REPO_TOKEN to CI_GITEA_TOKEN, consolidate env vars
## [0.10.1] - 2026-06-27
### Bug Fixes
- Set PYTHONPATH=src in publish Install CI tools step
### Refactor
- Replace duplicated Makefile targets with devx.mak aliases
- Consolidate publish.yml into post-merge.yml
## [0.10.0] - 2026-06-26
### Features
- Adopt devx tools, devx.mak fragment, ci extra, remove legacy install-devx
### Bug Fixes
- Always run publish in post-merge (idempotent)
## [0.9.0] - 2026-06-24
### Features
- Adopt devx v0.11.1 across Makefile and workflows
- Add Polish as officially supported language
## [0.8.1] - 2026-06-24
### Bug Fixes
- Repair publish workflow and add publish step to post-merge
- Add build/twine to ci deps, activate venv in notify_failure
## [0.8.0] - 2026-06-24
### Features
- Remove .taskid file, use branch name only for task ID
### Bug Fixes
- Add workflow_dispatch to publish workflow and update devx to 0.9.12
## [0.7.0] - 2026-06-24
### Features
- Switch devx installation from git to Gitea PyPI registry
- Adopt per-test timing quality gate from devx 0.7.0
### Bug Fixes
- Update devx to v0.4.2 and fix workflow env vars
- Pin devx to v0.4.3 to fix post-merge workflow failures
- Pin devx to v0.4.4 to fix validate-commit-msg and sync-wiki
- Rewrite CHANGELOG with correct version ordering and missing sections
- Lower test speed threshold to 4s and update devx to v0.8.2
- Retrospective fixes for CI/CD friction
- Replace stale badge SHA URLs with raw/branch/badges/
## [0.6.4] - 2026-06-22
### Bug Fixes
- Add self-updating quality badges to README
- Enforce mandatory PR reviews with automated checks
- Update devx to v0.4.2 and fix workflow env vars
- Pin devx to v0.4.3 to fix post-merge workflow failures
- Pin devx to v0.4.4 to fix validate-commit-msg and sync-wiki
## [0.6.3] - 2026-06-22
### Bug Fixes
- Add scripts/** to infrastructure classification config
## [0.6.2] - 2026-06-22
### Bug Fixes
- Include lint extras in setup-ci and setup-release
- Use commit SHA URLs for badges to bypass Gitea cache
- Make sync-wiki and vikunja depend on release
- Pin devx to v0.4.0, fix cliff.toml preprocessor, bump to v0.7.0
### Refactor
- Fully automate PR merge — no manual label/review needed
- Require tea CLI everywhere, fail on missing Vikunja task
- Separate GRM and CI translations with validation
- Migrate from scripts/ to devx package
## [0.6.1] - 2026-06-22
### Bug Fixes
- Badges always update on release commits + fix configure-repo PYTHONPATH
- Enforce commit message convention on master with CI validation
- Post-merge workflow failures (4 jobs)
## [0.6.0] - 2026-06-22
### Bug Fixes
- Bridge test suite gaps — lint scripts, include integration tests
- Badge regex patterns and doc_coverage double-percent
- Generate self-contained SVG badges instead of shields.io JSON
- Auto_merge handles single-token workflow (self-approval)
- Workflow timing, timeouts, status polling, and release classification
- API resilience with retry, idempotent releases, and graceful Vikunja errors
- Release pipeline determinism with lock, rebase, and consistent classification
- Enforce conventional commit check in automated PR review
- Molecule-tests matrix runner-index renders as empty for 0
- Use 1-based runner indices for Gitea Actions compatibility
- Molecule-tests static matrix and role_dir path fix
- Auto-merge label condition uses pull_request.labels
- Badges job runs after release to reflect actual state
- Revert review_pr.py to GiteaClient (tea v0.14.1 is interactive-only) (#70)
- Fix broken automation pipeline (auto-merge, Vikunja, CI enforcement)
### Refactor
- Consolidate CI workflows to eliminate redundant runs
- Convert shell scripts and inline workflow scripts to Python
- Enforce script separation and document import rules
### Revert
- Remove v0.6.0 release (no user-facing changes)
## [0.5.0] - 2026-06-21
### Packaging
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth)
- Added `console_scripts` entry point (`grm = "gitea_runner_manager.cli:cli"`)
### Bug Fixes
- Arch Linux: pacman cache update now runs separately before package installation (fixes idempotence)
- `rootless_docker.yml`: separated `update_cache` from package installation task
### Internal
- Added `GiteaClient.create_issue`, `GiteaClient.get_pr_files`, `GiteaClient.create_review` API methods
- Added `VikunjaClient.get_task` method
- Config URLs and repo settings now overridable via environment variables
## [0.4.0] - 2026-06-21
### Rootless Docker Support
- Full rootless Docker installation and configuration via Ansible
- `docker_rootless_setup` variable controls whether rootless Docker tasks run
- User setup tasks (subuid/subgid, lingering, dockerd-rootless)
- Proper gating of all Docker-dependent and `systemctl --user` tasks
### Runner Labels
- `--labels` option on `grm install` — specify runner labels (e.g., `--labels "ubuntu-latest:docker://node:20"`)
- Labels passed through to runner config YAML
### Security Fix (CWE-214)
- **Critical**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line
- Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`
- This prevents secrets from being visible in the process list (`ps aux`)
### Configuration via Environment Variables
- API URLs and repo configuration in `config.py` are now overridable via environment variables:
- `GRM_GITEA_API_URL`
- `GRM_VIKUNJA_API_URL`
- `GRM_REPO_OWNER`
- `GRM_REPO_NAME`
- `GRM_VIKUNJA_PROJECT_ID`
### Ansible Role Improvements
- Dead code cleanup (removed `config.yml`, legacy system-level service, duplicate task includes)
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup
- Arch Linux: `gnupg` package name fix, pacman cache handling
- Docker APT repository: deb822 format, proper GPG handling, arch mapping
- Idempotence fixes for user_setup and download tasks
## [0.3.0] - 2026-06-21
### New CLI Options
- `--force` flag on `grm remove` — remove a runner even when the host is unreachable (skips Ansible playbook, only deregisters via API)
- `--url` option — override the Gitea URL for any command (useful for multiple Gitea instances)
- `--ask-become-pass` is now the default behavior (no need to pass it explicitly)
### Status Detection Fixes
- `grm list` now correctly retrieves runner status (was showing "unknown" for active runners)
- Docker mode status detection via `docker inspect`
- Host/user context added to status output
### Output Improvements
- Colorized output for better visual feedback (green/red/yellow)
- Translated operation reports for success and failure cases
- Dual logging: `click.echo()` for user-facing messages, `logging` for debug
- `GRM_LOG_LEVEL` environment variable for controlling verbosity
- Full i18n support (all user-facing strings translated)
### Internal Refactoring
- Validation moved from CLI layer to business layer
- Centralized API clients and HTTP status codes
- User-friendly Click errors with i18n
## [0.2.0] - 2026-06-21
### New CLI Commands
- `grm start <host>` — start a runner's systemd service
- `grm stop <host>` — stop a runner's systemd service
- `grm enable <host>` — enable a runner to start on boot
- `grm disable <host>` — disable a runner from starting on boot
- `grm status <host>` — check runner service status
- `grm remove <host>` — deregister and remove a runner
- `grm list-runners` — list all runners from the local registry
### Runner Registry
- Runners are tracked in `~/.config/grm/runners.toml` for simplified CLI usage
- No need to specify `--url`, `--user`, `--key` for every command — the registry remembers
### Multi-Instance Support
- systemd template units (`gitea-runner@.service`) for running multiple runners per host
- Per-instance config and data directories
### Ansible Role Improvements
- Parameterized all hardcoded configuration values as Ansible variables
- Idempotence fixes for repeated runs
- Runner config converted from TOML to YAML format
- Registration timeout to prevent indefinite hangs
- Docker container entrypoint override and working directory fix for `.runner` persistence
## [0.1.0] - 2026-06-21
### Initial Release
The first release of GRM, a lean CLI for managing Gitea Actions runners via SSH.
### CLI Commands
- `grm install <host>` — install and register a Gitea Runner on a remote host via SSH
- `grm token` — generate a registration token via the Gitea API
- `grm list` — list all registered runners
- `grm update <host>` — update a runner to the latest version
### Ansible Role
- Installs Gitea Runner binary in binary or Docker mode
- Registers runner with Gitea instance
- Configures systemd service
- Supports Arch Linux, Ubuntu, and Debian
## [0.5.0] - 2026-06-22
### Features
- SSH-based remote execution via Ansible
- Automatic registration token generation
- Docker and binary installation modes
- Integration test verification after installation
- Enforce commit naming conventions and workflow discipline
### Bug Fixes
- Clean up infrastructure-only releases and fix release classification
- Rewrite changelog and re-tag releases at user-facing milestones
## [0.4.0] - 2026-06-21
### Features
- Replace inline workflow scripts with tested Python modules
- User-friendly click errors with i18n in configure_repo
- Bandit integration (#1)
- Auto-delete branch after merge in configure_repo script
- Add runner labels support and refactor i18n to JSON
- Parallel molecule runner with kill-on-first-failure
- Cross-runner molecule cancellation via Gitea API polling
### Bug Fixes
- Set runner_mode to binary in multi-instance converge
- Skip systemd operations in lifecycle molecule when unavailable
- Improve make setup with version guard, pre-push hooks and commit-msg validator
- Enforce GRM-N: conventional on master commits and PR titles
- Remove molecule tests from pre-push hooks
- Resolve bandit security warnings in source code and tests
- CI pipeline for rootless Docker runners
- CI workflows for rootless runner compatibility
- Vikunja task resolution pagination in post_merge.py
- Use PUT instead of POST for Vikunja task comments
- Parse pytest output with warnings in check_test_speed
- Use systemd as container command for rootless molecule tests
- Add Docker APT repository before installing docker-ce
- Use deb822_repository for Docker APT repo (proper GPG handling)
- Dearmor Docker GPG key with gpg --dearmor for apt_repository
- Use bash for gpg dearmor (pipefail not available in sh)
- Install curl, gpg, ca-certificates in molecule prepare
- Separate apt update after adding Docker repo, use variable for repo string
- Add apt source debug tasks, fix arch mapping for Docker repo
- Fail-fast CI, write Docker apt source directly, fix arch mapping
- Skip rootless Docker daemon startup in molecule tests
- Gate all Docker-dependent tasks behind docker_rootless_setup
- Catch TimeoutExpired in parallel runner wait loop
- Stream molecule subprocess output to CI logs
- Run molecule pairs sequentially within each CI runner
- Guard all systemctl --user tasks with docker_rootless_setup
- Guard handler systemctl --user calls with docker_rootless_setup
- Make user_setup and download tasks idempotent
- Use gnupg instead of gpg package name on Arch Linux
- Add default(0) to gitea_runner_uid in environment blocks
- Set runner_name in deregister verify.yml
- Security, dead code, idempotence, and documentation cleanup
- Use content_base64 for Gitea wiki API, add --verify flag (#33)
- Wiki links, add --strict integrity check for wiki sync (#34)
### Refactor
- Standardise pre-commit hooks on make targets
- Use http.HTTPStatus constants instead of magic numbers
- Rework all scripts to use click and i18n
- *(scripts)* Centralize constants, API clients, and HTTP status codes
- Rootless Docker, fix auto-merge, molecule platform matrix
## [0.3.2] - 2026-06-21
### Features
- Smart CI and release skipping for workflow-only changes
### Bug Fixes
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
### Refactor
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
## [0.3.1] - 2026-06-21
### Bug Fixes
- Use correct Gitea 1.26 wiki API endpoints
## [0.3.0] - 2026-06-21
### Features
- Implement documentation-as-code with wiki sync and doc-coverage
## [0.2.2] - 2026-06-21
### Bug Fixes
- Bypass commit-msg hook for release commits
- Enforce tests pass before tagging a release
## [0.2.1] - 2026-06-21
### Bug Fixes
- Strip git-cliff header from CHANGELOG.md updates
## [0.2.0] - 2026-06-18
### Features
- Parameterize all hardcoded configuration values as Ansible variables
- Add GITEA_ADMIN_TOKEN support for integration test
- Add AnsibleExecutor and i18n modules
- Integrate AnsibleExecutor and i18n into CLI and RunnerManager
- Add systemd template units and multi-instance Ansible support
- Add lifecycle CLI commands and RunnerManager extensions
- Add runner registry for simplified CLI UX
- Add translated operation report for success and failure cases
- Replace print() with stdlib logging module
- Use click.echo() for user-facing messages with dual logging
- Add colorized output for better visual feedback
- Add --force flag to grm remove for unreachable runners
- Make --ask-become-pass the default behavior
### Bug Fixes
- Resolve idempotence issues and testing infrastructure
- Remove recursive variable definitions in install and update playbooks
- Add timeout to runner registration to prevent indefinite hangs
- Override Docker container entrypoint to bypass run.sh wrapper
- Set Docker working dir to /data for .runner persistence
- Make integration test conditional on admin API accessibility
- Remove recursive var definitions from install-runner.yml
- Convert runner config from TOML to YAML format
- Rewrite integration test to verify .runner file and container health instead of unreliable API checks
- Eliminate duplicate console output, restore GRM_LOG_LEVEL filtering
- Make grm list retrieve runner status correctly
### Refactor
- Remove dead code and legacy artifacts
- Migrate source terminology from act_runner to gitea_runner
- Consolidate systemd checks and deduplicate role structure
- Deduplicate CLI, remove dead code, move validation to business layer
- Resolve_runner returns gitea_url, add --url CLI option, force remove improvements, code quality fixes
## [0.1.0] - 2026-06-17
### Features
- Initial implementation of Gitea Runner Manager
+118 -64
View File
@@ -1,4 +1,6 @@
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all 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
.PHONY: configure-gitea-pypi
.PHONY: create-task create-pr push-with-pr git-push
PYTHON := python3
VENV := .venv
@@ -7,8 +9,85 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
all: setup
setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools
@$(PYTHON) scripts/setup.py --bin "$(BIN)"
# --- devx.mak include (shared Makefile targets) -------------------------------
# Set DEVX_PYTHON before including devx.mak so it uses the venv Python.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_COV_PKG := src/gitea_runner_manager
DEVX_TEST_PATHS := tests/ scripts/tests/
DEVX_LINT_PATHS := src/ scripts/ tests/
# Include shared targets from devx package (create-task, create-pr, push-with-pr,
# check-config, workflow-lint, lint-ruff, clean, venv, .env, activate-scripts,
# install-hooks, install-tools, configure-gitea-pypi, checkmake, etc.)
# Silent if devx not installed yet — run 'make setup' first.
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
-include $(DEVX_MAK)
# Full setup for local development (all deps, tools, collections, hooks)
# devx is installed via pip install -e .[dev] (devx is in dev extra)
setup: $(VENV)/bin/activate .env activate-scripts configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[dev]'
@$(BIN)/python -m devx.tools.install_checkmake
@$(BIN)/python -m devx.tools.install_tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install
# Lean setup for CI jobs that need pytest + lint tools + runtime deps
# (detect-changes, discover-runners, pr-review, sync-wiki, badges)
# badges job runs generate_badges.py which needs ruff, pyright, bandit
setup-ci: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit --no-tea-login
# Setup for the quality job (lint + test deps, actionlint tool)
setup-quality: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.install_tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit --no-tea-login
# Full setup for molecule testing (needs ansible, molecule, collections)
setup-molecule: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,molecule]'
@$(BIN)/python -m devx.tools.install_tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-pre-commit --no-tea-login
# Setup for release jobs (needs git-cliff, tea, and lint tools for release.py)
setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
# Usage: make setup-image (runtime deps only, devx from image)
# make setup-image EXTRAS=lint (runtime + lint deps, e.g. ansible-lint)
# make setup-image EXTRAS=ci,lint (runtime + ci + lint deps, upgrades devx)
# NOTE: Cannot alias to devx-setup-image because the venv must exist before
# devx.mak can be included (chicken-and-egg). This standalone target creates
# the venv symlink first, then installs the project.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
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; \
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 \
@@ -16,27 +95,11 @@ setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
$(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
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)
install-hooks:
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
@echo "Git hooks installed."
checkmake:
@python3 scripts/install_checkmake.py
install-tools:
@$(PYTHON) scripts/install_tools.py
install:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
@@ -69,58 +132,47 @@ 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,)
lint-ruff:
$(BIN)/ruff check src/ tests/ scripts/
# --- Aliases to devx.mak targets ----------------------------------------------
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
lint-bandit: devx-lint-bandit
lint-deps: devx-lint-deps
lint: devx-lint
checkmake: devx-checkmake
install-tools: devx-install-tools
install-hooks: devx-install-hooks
clean: devx-clean
test-unit: devx-test-unit
lint-format:
$(BIN)/ruff format --check src/ tests/ scripts/
# Override devx-pytest-cov to cover both src/ and scripts/
pytest-cov:
@$(BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
workflow-lint: devx-workflow-lint
workflow-dryrun: devx-workflow-dryrun
workflow-check: devx-workflow-check
typecheck:
$(BIN)/pyright
lint: lint-ruff lint-format typecheck lint-bandit
lint-bandit:
$(BIN)/bandit -r src/ scripts/
lint-deps:
@echo "Checking dependencies for known vulnerabilities..."
@.venv/bin/python -m ensurepip 2>/dev/null || true
@PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python \
.venv/bin/pip-audit --desc --skip-editable 2>&1 || true
configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
ansible-lint:
$(BIN)/ansible-lint ansible/
PATH="$(PWD)/$(BIN):$$PATH" $(BIN)/ansible-lint ansible/
makefile-lint:
@$(CHECKMAKE) Makefile
@if command -v $(CHECKMAKE) >/dev/null 2>&1 || [ -x "$(CHECKMAKE)" ]; then \
$(CHECKMAKE) Makefile; \
else \
echo "checkmake not found, skipping Makefile lint"; \
fi
lint-all: lint ansible-lint makefile-lint workflow-lint
workflow-lint:
@command -v actionlint >/dev/null 2>&1 || { \
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
exit 1; \
}
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
workflow-dryrun:
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
@echo "Dry-running all workflows (no Docker containers started)..."
act_runner exec --dryrun -W .gitea/workflows/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
workflow-check: workflow-lint workflow-dryrun
@echo "Workflow checks passed (static lint + dry-run)."
test-unit:
$(BIN)/pytest tests/unit/ -v --no-cov
test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov
pytest-cov:
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
MOLECULE := $(realpath $(BIN))/molecule
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
@@ -130,13 +182,15 @@ molecule:
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
molecule-all:
@$(PYTHON) scripts/molecule_all.py --bin "$(BIN)"
@$(BIN)/python -m devx.molecule.molecule_all --bin "$(BIN)"
test: test-all
test-all: pytest-cov molecule
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
rm -rf .coverage htmlcov/ .molecule/
# --- Vikunja task and PR management (via devx.mak fragment) -------------------
# Aliases for project-specific target names
create-task: devx-create-task
create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
+290 -10
View File
@@ -2,18 +2,42 @@
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts.
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts. GRM handles the entire runner lifecycle — from initial installation and registration with Gitea, through start/stop/enable/disable operations, to clean removal with deregistration.
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![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/branch/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/python.svg)](https://www.python.org/downloads/)
## Why GRM?
Managing Gitea Actions runners manually is tedious and error-prone: you need to create system users, set up rootless Docker, download and configure the runner binary, register it with Gitea, create systemd services, and set up Docker prune timers — all per runner instance. GRM automates this entire process with a single command, and ensures it is idempotent (safe to re-run).
Key problems GRM solves:
- **Isolation without root**: Each runner operates under a dedicated system user with its own rootless Docker daemon, so runners on the same host never interfere with each other or with the host's Docker installation.
- **Reproducible setup**: The Ansible role is idempotent — running `grm install` twice produces zero changes on the second run, so it is safe for CI/CD pipelines and configuration management.
- **Full lifecycle management**: Install, start, stop, enable (boot persistence), disable (deregister), update the binary, check status, and remove — all from one CLI.
- **Local registry**: GRM stores connection metadata locally, so after installation you manage runners by name alone without repeating SSH credentials.
## Features
- **Rootless Docker isolation** — Each runner gets its own rootless Docker daemon under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
- **Multi-instance support** — Install and manage multiple isolated runners on the same host, each with independent users, data directories, and systemd user services.
- **Idempotent Ansible role** — Safe to re-run; the role detects existing state and only applies changes when needed.
- **Full lifecycle CLI** — `install`, `update`, `start`, `stop`, `enable`, `disable`, `status`, `remove`, `list` — all from a single `grm` command.
- **Automatic integration testing** — Every installation runs an integration test that verifies the `.runner` registration file and systemd service state.
- **Docker prune automation** — A systemd user timer automatically prunes old Docker images and volumes on a daily schedule.
- **Local runner registry** — Connection details are stored in `~/.local/share/grm/runners.json`, so lifecycle commands work by runner name alone.
- **Internationalisation** — Console messages support English, Bulgarian, German, Russian, Chinese, and Polish via the `GRM_LANG` environment variable.
- **Security-conscious** — Secrets (registration tokens) are passed via temporary JSON files with `0600` permissions, never on the command line (CWE-214).
- **Comprehensive CI/CD** — 100% test coverage, automated releases via conventional commits and git-cliff, Molecule tests across 4 OS platforms.
## Quick Start
@@ -26,10 +50,257 @@ cp .env.example .env # Edit with your Gitea URL and tokens
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release.
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
> **Tokens:** You need two tokens from your Gitea instance — a **registration token** to register runners, and an **admin API token** for optional post-install verification. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed setup instructions.
## Prerequisites
### On your local machine (where you run `grm`)
- **Python 3.12+** — GRM targets Python 3.12 and requires it for development setup.
- **Ansible** — Installed automatically by `make setup` (via pip). GRM delegates all remote operations to `ansible-playbook`.
- **SSH access** — A private key that grants access to the target host(s) as a user with sudo privileges.
### On the target host(s) (where runners will be installed)
- **SSH server** — Reachable via the key specified with `--key`.
- **Sudo access** — The SSH user must have sudo privileges for creating system users, installing packages, and configuring rootless Docker. By default, you will be prompted for the sudo password interactively. For automation, configure passwordless sudo and pass `--no-ask-become-pass`.
- **Docker** — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
- **systemd** — Required for user services and lingering. All supported OSes ship with systemd.
## Installation
### Option 1: From source (recommended for full control)
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
```
`make setup` performs the following:
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs all Python dependencies (including Ansible, Click, python-dotenv)
4. Creates `.env` from `.env.example` if not present
5. Installs development tools (actionlint, git-cliff, act_runner, checkmake)
6. Sets up pre-commit hooks
### Option 2: Via pip (for using GRM without the full repo)
GRM is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
**Quick install (one-off):**
```bash
pip install gitea-runner-manager --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
**Persistent configuration (recommended):**
Add the registry to `~/.pip/pip.conf` so future `pip install` commands find
GRM automatically:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then install normally:
```bash
pip install gitea-runner-manager
```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks
and role are bundled with the package, so `grm install` works out of the box.
For development or access to Make targets, clone the repository (Option 1).
### Post-install configuration
After installation, create your `.env` file:
```bash
cp .env.example .env
# Edit .env with your Gitea URL and registration token
```
See the [Configuration](#configuration) section below for details.
## CLI Commands Overview
GRM provides a single `grm` command with subcommands for the full runner lifecycle:
| Command | Description |
|---------|-------------|
| `grm install <host>` | Install and configure a runner on a remote host |
| `grm update <host>` | Update the Gitea Runner binary on a remote host |
| `grm start <name>` | Start a registered runner |
| `grm stop <name>` | Stop a registered runner |
| `grm enable <name>` | Enable a runner to start on boot |
| `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 list` | List all registered runners with live status |
| `grm --version` | Show the installed version |
All lifecycle commands (`start`, `stop`, `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`.
See the [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CLI-Commands.-) wiki page for full argument and option reference.
## Configuration
GRM reads configuration from a `.env` file in the current directory (loaded automatically via python-dotenv). You can also set environment variables directly.
### Required variables
| Variable | Description |
|----------|-------------|
| `GITEA_URL` | Your Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Runner registration token from Gitea (starts with `GR`) |
### Optional variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CI_GITEA_TOKEN` | — | Gitea admin API token for optional post-install API verification |
| `GITEA_INTEGRATION_RETRIES` | `3` | Number of API check retries during integration test |
| `GITEA_RUNNER_USER` | current login | Default SSH user (overrides `--user`) |
| `GITEA_RUNNER_KEY` | — | Default SSH key path (overrides `--key`) |
| `GITEA_RUNNER_LABELS` | — | Default runner labels (overrides `--labels`) |
| `GRM_LANG` | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh` |
| `GRM_LOG_LEVEL` | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
### Getting tokens
**Registration token** (required): Navigate to your Gitea instance:
- **Instance-level**: Site Administration → Actions → Runners → Create Registration Token
- **Organization-level**: Organization → Settings → Actions → Runners → Create Registration Token
- **Repository-level**: Repository → Settings → Actions → Runners → Create Registration Token
Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
**Admin API token** (optional): Settings → Applications → Generate New Token, with the `admin` scope (or at minimum: `read:user`, `read:repository`, `read:admin`). When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational and does not affect pass/fail.
## Multi-Instance Support
One of GRM's core features is the ability to run multiple isolated runners on the same host. Each runner instance gets:
- **Dedicated system user**: `grm-<name>` with its own home directory at `/home/grm-<name>/`
- **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
- **Data directory**: `/var/lib/gitea-runner/<name>/`
- **Config directory**: `/etc/gitea-runner/<name>/`
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
- **Docker prune timer**: Per-instance daily cleanup
```bash
# Install two runners on the same host
grm install 192.168.1.10 --user ubuntu --name workflow-runner
grm install 192.168.1.10 --user ubuntu --name build-runner
# Manage them independently by name
grm stop workflow-runner
grm status build-runner
grm list
```
## Security Model
GRM is designed with security as a first-class concern:
- **Rootless Docker**: Each runner operates under a dedicated unprivileged system user. The Docker daemon runs in rootless mode, so containers never have root access to the host. User namespaces (`subuid`/`subgid`) are configured automatically.
- **Dedicated users**: Each runner gets its own system user (`grm-<name>`) with lingering enabled, so the user's systemd services run without an active login session.
- **Secret handling**: Registration tokens and admin tokens are never passed on the command line. They are written to temporary JSON files with `0600` permissions and passed to Ansible via `--extra-vars @tempfile`. The temp file is deleted immediately after execution. This prevents secrets from being visible in the process list (`ps aux`), addressing CWE-214.
- **No shell injection**: The CLI never uses `shell=True` with subprocess. All Ansible commands are constructed as argument lists.
- **Bandit security scan**: The CI pipeline runs Bandit on every PR to catch common Python security issues.
## Supported Operating Systems
GRM supports and tests the following operating systems:
| OS | Versions | Package manager |
|----|----------|-----------------|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files. The platform matrix is defined in `devx.molecule.platforms` as the single source of truth.
## Development Setup
GRM uses a comprehensive development setup with 100% test coverage enforcement, multiple linters, and Molecule integration tests.
### Quick development setup
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
```
### Make targets
| Target | Description |
|--------|-------------|
| `make setup` | Full setup: venv, deps, hooks, CI tools |
| `make lint-all` | ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
| `make test-unit` | Unit tests without coverage |
| `make molecule` | All 6 Molecule scenarios on Ubuntu 22.04 |
| `make molecule-all` | All 6 scenarios on all 4 supported OSes |
| `make test-all` | pytest-cov + molecule |
| `make workflow-lint` | Static lint of workflow YAML (actionlint) |
| `make workflow-dryrun` | Dry-run all workflows in Docker |
| `make workflow-check` | workflow-lint + workflow-dryrun |
See the [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Development-Setup.-) wiki page for full details.
## Architecture Overview
GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
```
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner
├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service)
├── prune.yml (Docker prune timer)
└── integration_test.yml (validate service is active)
```
### Python modules
| Module | Description |
|--------|-------------|
| `cli.py` | Click-based CLI entry point — defines all commands |
| `runner_manager.py` | Ansible orchestration + registry integration |
| `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) |
| `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 |
See the [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) wiki page for the full component diagram and data flow.
## Documentation
Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki).
@@ -44,13 +315,22 @@ Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi
### Technical Documentation
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions, data flow
- [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Development-Setup.-) — Environment setup, dependencies, local testing
- [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CI-CD-Workflow.-) — How CI works, release process, branch protection
- [Testing Strategy](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Testing-Strategy.-) — Unit, integration, and Molecule tests
- [Decision Log](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Decision-Log.-) — Key technical decisions and rationale
- [Contributing Guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing-Guide.-) — Coding standards, PR workflow, commit rules
## Links
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
- [Releases](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
- [Issues](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/issues)
- [CI/CD Pipeline](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
- [Changelog](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/CHANGELOG.md)
- [Wiki](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
## License
GPL-3.0
GPL-3.0 — See [LICENSE](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE) for the full text.
-100
View File
@@ -1,100 +0,0 @@
# Review Checklist
This checklist is **mandatory** for every PR. The automated `pr-review` CI
job checks items marked **[auto]**. The agent must verify all items
marked **[manual]** before posting an APPROVE review.
The `review_pr.py` script requires `--checklist-confirmed` for APPROVE
events. This flag attests that every category below has been reviewed.
---
## 1. Architecture Compliance [auto + manual]
- [ ] **No business logic in CLI** (`cli.py`): no `subprocess`, no
`os.system`, no `ansible-playbook` — delegate to `executor.py`
- [ ] **No hardcoded URLs or config values** that belong in `config.py`
with env var overrides
- [ ] **Layer boundaries respected**: CLI → runner_manager → executor →
subprocess/Ansible. No skipping layers.
- [ ] **Single Responsibility**: each module/function has one reason to
change. If a function does two things, split it.
- [ ] **No circular imports** introduced
## 2. Code Quality and Best Practices [auto + manual]
- [ ] **No `print()`** in `src/` — use `click.echo()` for user output
- [ ] **No bare `except:`** — catch specific exceptions
- [ ] **No broad `except Exception:`** without justification
- [ ] **No `TODO`/`FIXME`/`HACK`/`XXX`** left in merged code
- [ ] **No functions > 50 lines** (excluding docstrings and decorators)
- [ ] **No dead code** — unused imports, unreachable branches, commented-out code
- [ ] **No copy-paste duplication** — extract shared logic into a helper
- [ ] **Idiomatic Python** — use comprehensions, context managers, dataclasses
- [ ] **Type hints** on all public functions
## 3. Security [auto + manual]
- [ ] **No hardcoded secrets** (tokens, passwords, keys in string literals)
- [ ] **No `shell=True`** in subprocess calls — use argument lists
- [ ] **No `eval()` or `exec()`** — use `ast.literal_eval` if parsing literals
- [ ] **No secrets in logs or process arguments** — pass via env vars or files
- [ ] **Input validation** on all external inputs (CLI args, API responses, file contents)
- [ ] **No injection vectors** — parameterize subprocess args, SQL queries, etc.
## 4. Internationalization (i18n) [manual]
- [ ] **All user-facing strings wrapped in `_()`** — `click.echo(_("..."))`,
error messages, help text, prompts
- [ ] **No raw English strings** in `click.echo()`, `click.ClickException()`,
or `raise` messages visible to users
- [ ] **String interpolation uses named placeholders**: `_("Hello {name}", name=x)`
not `f"Hello {x}"` for translatable strings
## 5. Testability and Test Coverage [auto + manual]
- [ ] **Source file changes include corresponding test updates**
- [ ] **100% coverage maintained** (enforced by `pytest-cov`)
- [ ] **Tests are fast** (< 10 seconds total, enforced by `check_test_speed.py`)
- [ ] **Edge cases tested**: empty inputs, boundary values, error paths
- [ ] **No flaky tests** — no `sleep()`, no race conditions, no external dependencies
- [ ] **Test names describe the scenario**: `test_<condition>_<expected_result>`
## 6. Performance [manual]
- [ ] **No unnecessary allocations** in hot paths (list comprehensions vs generators)
- [ ] **Correct data structures** — O(1) lookups use `set`/`dict`, not `list`
- [ ] **No N+1 query patterns** in API calls or file I/O
- [ ] **No blocking I/O on hot paths** without justification
## 7. User Experience [manual]
- [ ] **Clear error messages** — tell the user what went wrong and how to fix it
- [ ] **Consistent CLI flag naming** — `--long-name` with `--short` aliases
- [ ] **Help text on all commands and options** — `--help` should be useful
- [ ] **No silent failures** — if something fails, the user should know
- [ ] **Output is actionable** — not just "Error" but "Error: X failed because Y. Try Z."
## 8. Documentation [auto + manual]
- [ ] **Source changes include doc updates** — README, wiki, AGENTS.md as needed
- [ ] **New functions/classes have docstrings** — Google style
- [ ] **Public API changes documented** in CHANGELOG (auto-generated by git-cliff)
- [ ] **AGENTS.md updated** if workflow, conventions, or processes changed
- [ ] **No stale documentation** — if code changed, docs must reflect it
## 9. Workflow Compliance [manual]
- [ ] **PR title matches Vikunja task title** (`GRM-N: <task title>`)
- [ ] **Commit messages follow conventional format** (`type: description`)
- [ ] **No force-push after review** — creates new commits and re-trigger CI
- [ ] **Branch is up to date** with master before merging
- [ ] **No merge commits** in the PR branch — use squash merge via auto-merge
## 10. Extensibility and Maintainability [manual]
- [ ] **Open/Closed Principle** — code is open for extension, closed for modification
- [ ] **No magic numbers** — constants are named and documented
- [ ] **Configuration over hardcoding** — use `config.py` with env var overrides
- [ ] **Future-proof error handling** — don't catch specific error messages that may change
- [ ] **Dependencies are justified** — no new dependency without rationale
+2 -2
View File
@@ -9,8 +9,8 @@
| 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 |
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
| `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` |
+58 -1
View File
@@ -40,6 +40,30 @@
changed_when: true
failed_when: false
- name: Force-remove all Docker containers (rootless)
ansible.builtin.shell: |
set -o pipefail
docker ps -aq 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
args:
executable: /bin/bash
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
changed_when: false
failed_when: false
- name: Prune all Docker images, volumes, and build cache (rootless)
ansible.builtin.command: docker system prune -af --volumes
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
changed_when: false
failed_when: false
- name: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker
become: true
@@ -55,11 +79,23 @@
tasks_from: deregister.yml
when: not skip_runner_registration | default(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"
state: absent
failed_when: false
- name: Remove docker-prune user timer file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.timer"
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"
state: absent
when: remove_systemd_template | default(false)
when: remove_systemd_template | default(true)
- name: Kill remaining processes of runner user
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
@@ -84,6 +120,27 @@
when: remove_runner_user | default(true)
failed_when: false
- name: Remove Docker data root when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.local/share/docker"
state: absent
when: not (remove_runner_user | default(true))
failed_when: false
- name: Remove act cache when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.cache/act"
state: absent
when: not (remove_runner_user | default(true))
failed_when: false
- name: Remove systemd user config dir when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user"
state: absent
when: not (remove_runner_user | default(true))
failed_when: false
- name: Remove subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
+48
View File
@@ -0,0 +1,48 @@
---
- name: Restart Gitea Actions runner (stop, prune images, start)
hosts: all
become: true
vars:
prune_images: true
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
- name: Prune stale runner images from rootless Docker
ansible.builtin.command:
cmd: python3 {{ playbook_dir }}/../scripts/prune_runner_images.py
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
when:
- systemd_available.stat.exists
- prune_images | default(true)
changed_when: true
failed_when: false
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
@@ -24,6 +24,10 @@ gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Removal defaults
remove_systemd_template: true
remove_runner_user: true
# Runner configuration
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "remove-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,39 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
side_effect: side_effect.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,8 @@
---
- name: Remove runner via remove-runner playbook
ansible.builtin.import_playbook: "../../../../remove-runner.yml"
vars:
runner_name: "remove-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: true
@@ -0,0 +1,114 @@
---
- name: Verify runner was fully removed
hosts: all
become: true
vars:
runner_name: "remove-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check runner user is absent
ansible.builtin.getent:
database: passwd
key: "{{ gitea_runner_service_user }}"
register: user_check
failed_when: false
- name: Assert runner user is absent
ansible.builtin.assert:
that:
- user_check is failed or
gitea_runner_service_user not in (user_check.ansible_facts.getent_passwd | default({}))
fail_msg: "Runner user still exists after removal"
- name: Check runner home directory is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}"
register: home_stat
- name: Assert runner home directory is absent
ansible.builtin.assert:
that:
- not home_stat.stat.exists
fail_msg: "Runner home directory still exists after removal"
- name: Check runner data directory is absent
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}"
register: data_stat
- name: Assert runner data directory is absent
ansible.builtin.assert:
that:
- not data_stat.stat.exists
fail_msg: "Runner data directory still exists after removal"
- name: Check runner config directory is absent
ansible.builtin.stat:
path: "{{ gitea_runner_config_dir }}"
register: config_stat
- name: Assert runner config directory is absent
ansible.builtin.assert:
that:
- not config_stat.stat.exists
fail_msg: "Runner config directory still exists after removal"
- name: Check gitea-runner service unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert gitea-runner service unit is absent
ansible.builtin.assert:
that:
- not service_stat.stat.exists
fail_msg: "gitea-runner service unit still exists after removal"
- name: Check docker-prune service unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
register: prune_service_stat
- name: Assert docker-prune service unit is absent
ansible.builtin.assert:
that:
- not prune_service_stat.stat.exists
fail_msg: "docker-prune service unit still exists after removal"
- name: Check docker-prune timer unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
register: prune_timer_stat
- name: Assert docker-prune timer unit is absent
ansible.builtin.assert:
that:
- not prune_timer_stat.stat.exists
fail_msg: "docker-prune timer unit still exists after removal"
- name: Check subuid entry is absent
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subuid"
register: subuid_check
changed_when: false
failed_when: false
- name: Assert subuid entry is absent
ansible.builtin.assert:
that:
- subuid_check.rc != 0
fail_msg: "subuid entry still exists after removal"
- name: Check subgid entry is absent
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subgid"
register: subgid_check
changed_when: false
failed_when: false
- name: Assert subgid entry is absent
ansible.builtin.assert:
that:
- subgid_check.rc != 0
fail_msg: "subgid entry still exists after removal"
@@ -0,0 +1,25 @@
---
# Resolve runner identity facts for stop/start/status/restart playbooks.
# These playbooks use include_role with tasks_from, which does NOT expose
# role defaults to the playbook's task-level keywords (become_user, etc).
# We set the facts explicitly here so they're available everywhere.
- name: Resolve runner service user
ansible.builtin.set_fact:
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ runner_name }}"
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
- name: Resolve runner data and config dirs
ansible.builtin.set_fact:
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
- name: Resolve runner service user UID
ansible.builtin.getent:
database: passwd
key: "{{ gitea_runner_service_user }}"
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ getent_passwd[gitea_runner_service_user][1] }}"
@@ -20,6 +20,7 @@
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists
when: systemd_available.stat.exists
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
+8 -3
View File
@@ -9,9 +9,14 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
- name: Include registration if not registered
@@ -25,8 +30,8 @@
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
+8 -3
View File
@@ -9,12 +9,17 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Check systemd user service status
ansible.builtin.command: systemctl --user is-active gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: service_status
changed_when: false
when: systemd_available.stat.exists
@@ -26,7 +31,7 @@
- name: Check runner registration file
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Report runner registration
+7 -2
View File
@@ -9,11 +9,16 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
+5 -2
View File
@@ -39,8 +39,8 @@ sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
{ pattern = "^GRM-\\d+\\s+", replace = "" },
# Strip GRM-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^GRM-\\d+:\\s+", replace = "" },
]
commit_parsers = [
@@ -66,3 +66,6 @@ commit_parsers = [
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
# affect users even though no new feature was added.
refactor_always_bump_patch = true
+47 -15
View File
@@ -2,34 +2,66 @@
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts. GRM handles the entire runner lifecycle — from initial installation and registration with Gitea, through start/stop/enable/disable operations, to clean removal with deregistration.
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![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/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/fc36a6d7330ffc2a6e68e3f8ce5a1dcd3b2c0983/python.svg)](https://www.python.org/downloads/)
## Overview
GRM is a two-layer tool: a Python CLI (built with Click) that delegates to an idempotent Ansible role for all remote operations. The CLI handles argument parsing, environment loading, internationalisation, and local registry management. The Ansible role handles the actual runner setup — creating dedicated system users, configuring rootless Docker, downloading and registering the runner binary, creating systemd user services, and setting up Docker prune timers.
### Key capabilities
- **Rootless Docker isolation** — Each runner gets its own rootless Docker daemon under a dedicated system user (`grm-<name>`).
- **Multi-instance support** — Multiple isolated runners on the same host, each with independent users, data directories, and systemd services.
- **Full lifecycle CLI** — `install`, `update`, `start`, `stop`, `enable`, `disable`, `status`, `remove`, `list`.
- **Idempotent Ansible role** — Safe to re-run; second run produces zero changes.
- **Automatic integration testing** — Every installation verifies the `.runner` registration file and systemd service state.
- **Local runner registry** — Connection details stored locally; manage runners by name after installation.
- **Internationalisation** — Console messages in English, Bulgarian, German, Russian, Chinese, and Polish.
- **Security-conscious** — Secrets passed via temporary JSON files with `0600` permissions (CWE-214).
### Supported operating systems
| OS | Versions | Package manager |
|----|----------|-----------------|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
## User Documentation
- [Getting Started](Getting-Started.-) — Installation, quick start, first run
- [Installation](Installation) — Prerequisites, setup, multiple instances
- [CLI Commands](CLI-Commands.-) — All commands with arguments and options
- [Troubleshooting](Troubleshooting) — Common issues and solutions
- [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
- [Troubleshooting](Troubleshooting) — Common issues, diagnostics, and solutions
- [FAQ](FAQ) — Frequently asked questions
## Technical Documentation
- [Architecture](Architecture) — High-level design, component interactions, data flow
- [Development Setup](Development-Setup.-) — Environment setup, dependencies, local testing
- [CI/CD Workflow](CI-CD-Workflow.-) — How CI works, release process, branch protection
- [Testing Strategy](Testing-Strategy.-) — Unit, integration, and Molecule tests
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit rules
- [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
## Quick Links
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
- [Releases](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
- [Issues](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/issues)
- [CI/CD Pipeline](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
- [Changelog](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/CHANGELOG.md)
- [License (GPL-3.0)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
+142 -6
View File
@@ -5,6 +5,21 @@ GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
## High-Level Design
The CLI is a thin orchestration layer. It does not perform any remote operations itself — every action (install, update, start, stop, etc.) is delegated to an Ansible playbook. The CLI's responsibilities are:
- Parsing command-line arguments and options
- Loading configuration from `.env` (via python-dotenv)
- Resolving runner connection details from the local registry
- Writing secrets to temporary JSON files (CWE-214 mitigation)
- Constructing the `ansible-playbook` command with appropriate inventory, user, key, and extra-vars
- Capturing and streaming Ansible output to log files
- Maintaining the local runner registry (`~/.local/share/grm/runners.json`)
- Providing colorised console output and operation reports
The Ansible role handles all remote state: user creation, package installation, Docker configuration, binary download, runner registration, systemd service management, and Docker prune timers.
## Component Tree
```
@@ -30,6 +45,33 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
### Ansible task files
| Task file | Responsibility |
|-----------|---------------|
| `main.yml` | Entry point — includes all other task files in order |
| `systemd_check.yml` | Verifies systemd is available on the target host |
| `user_setup.yml` | Creates the per-runner system user, enables lingering, configures subuid/subgid, creates data and config directories |
| `rootless_docker.yml` | Installs Docker packages (apt for Debian/Ubuntu, pacman for Arch), runs `dockerd-rootless-setuptool.sh install`, starts and enables the rootless Docker daemon |
| `install_runner.yml` | Downloads the gitea_runner binary, creates the config file, validates the binary, registers the runner with Gitea, creates and starts the systemd user service |
| `download_gitea_runner.yml` | Downloads the gitea_runner binary from GitHub releases |
| `validate.yml` | Validates the downloaded binary |
| `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 |
| `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 |
### Ansible templates
| Template | Purpose |
|----------|---------|
| `gitea-runner-user.service.j2` | Systemd user service for the gitea_runner daemon |
| `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 |
## Per-Runner Isolation
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
@@ -40,6 +82,9 @@ Each runner runs as a systemd user service under a dedicated system user (`grm-<
- **Config**: `/etc/gitea-runner/<name>/`
- **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
- **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping)
Lingering is enabled via `loginctl enable-linger` so the user's systemd services run without an active login session. This is essential for runners that need to operate continuously.
## Component Interactions
@@ -58,11 +103,13 @@ flowchart TD
TEST["integration_test.yml<br/>validate service active"]
GITEA["Gitea instance<br/>registration + API"]
SYSTEMD["systemd user service<br/>gitea-runner.service"]
LOG["Log files<br/>~/.local/state/grm/logs/"]
CLI --> RM
RM --> REG
RM --> EXEC
EXEC -->|subprocess| ANS
EXEC -->|stream output| LOG
ANS --> ROLE
ROLE --> USER
ROLE --> DOCKER
@@ -72,14 +119,85 @@ flowchart TD
INSTALL -->|register| GITEA
INSTALL --> SYSTEMD
DOCKER --> SYSTEMD
TEST -->|optional API check| GITEA
```
## Data Flow
### Installation flow
1. User runs `grm install <host> --user <user> --key <key> --name <name>`
2. CLI loads `.env` for `GITEA_URL` and `GITEA_REGISTRATION_TOKEN`
3. `RunnerManager.install()` constructs extra-vars dict with registration token, runner name, Gitea URL, and optional admin token/labels
4. Extra-vars are written to a temporary JSON file with `0600` permissions
5. `AnsibleExecutor.run()` invokes `ansible-playbook ansible/install-runner.yml` with the temp file via `--extra-vars @tempfile`
6. Ansible connects to the remote host via SSH and executes the role:
- Creates system user `grm-<name>` with lingering
- Installs Docker packages and sets up rootless Docker
- Downloads the gitea_runner binary
- Creates the runner config file
- Registers the runner with Gitea
- Creates and starts the systemd user service
- Sets up the Docker prune 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`
9. The temporary extra-vars file is deleted
### Lifecycle command flow
1. User runs `grm <command> <runner_name>` (e.g., `grm stop prod-runner`)
2. `RunnerManager._resolve_runner()` looks up the runner in the local registry
3. If `--host` and `--user` are provided, they override registry values
4. The corresponding playbook is executed (e.g., `stop-runner.yml`)
5. Ansible connects to the remote host and performs the action
### List command flow
1. User runs `grm list`
2. `RunnerManager.list_runners()` reads all entries from the local registry
3. For each runner, an Ansible ad-hoc command checks `systemctl --user is-active gitea-runner`
4. Results are displayed in a table with columns: NAME, HOST, USER, LABELS, STATUS
## Security Model
### Rootless Docker
Each runner operates under a dedicated unprivileged system user. The Docker daemon runs in rootless mode via `dockerd-rootless-setuptool.sh install`, which configures:
- User namespace mapping via `/etc/subuid` and `/etc/subgid` (range: 100000-165535)
- Rootless Docker socket at `/run/user/<UID>/docker.sock`
- `slirp4netns` for user-mode networking
- `fuse-overlayfs` for rootless container storage
Containers launched by the runner never have root access to the host. The rootless Docker daemon is started as a systemd user service and persists via lingering.
### Secret handling
Registration tokens and admin API tokens are never exposed on the command line. The `RunnerManager._extra_vars_file()` context manager:
1. Creates a temporary file via `tempfile.mkstemp()`
2. Writes the extra-vars JSON to the file
3. Sets permissions to `0600` (owner read/write only)
4. Passes the file to Ansible via `--extra-vars @tempfile`
5. Deletes the file in a `finally` block, even if an exception occurs
This prevents secrets from appearing in the process list (`ps aux`), addressing CWE-214.
### No shell injection
The CLI never uses `shell=True` with subprocess. All Ansible commands are constructed as argument lists (`list[str]`), preventing shell injection attacks. The `subprocess.Popen` and `subprocess.run` calls are marked with `nosec` comments after security review.
### Bandit security scanning
The CI pipeline runs Bandit on every PR to catch common Python security issues. The scan covers all source code in `src/`.
## Additional Components
From `AGENTS.md`, the project also includes:
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications. This package is not part of the GRM tool itself — it provides the CI/CD automation infrastructure.
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits.
## Python Modules
@@ -89,9 +207,27 @@ The Python CLI layer (`src/gitea_runner_manager/`) consists of the following mod
|--------|-------------|
| `cli.py` | Click-based CLI entry point — defines all commands (install, update, start, stop, enable, disable, status, remove, list) |
| `runner_manager.py` | Ansible orchestration + registry integration — delegates to executor and manages runner lifecycle |
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files |
| `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` | Internationalization translations (en, bg, de, ru, zh) |
| `exceptions.py` | Custom exceptions (`GRMError`, `APIError`) |
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts |
| `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 |
| `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
GRM writes to two destinations:
| Destination | Level | Content |
|-------------|-------|---------|
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
| `~/.local/state/grm/logs/ansible-<timestamp>.log` | — | Full Ansible playbook output per execution |
Console output is automatically colorised via `click.style`: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow. The log file always captures plain text (no ANSI codes) at DEBUG level regardless of the console setting.
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity.
+47 -26
View File
@@ -1,5 +1,16 @@
# CI/CD Workflow
GRM uses a fully automated CI/CD pipeline built on Gitea Actions. Every change to master goes through a mandatory PR workflow with branch protection, automated review, and auto-merge. Releases are automated via git-cliff and conventional commits.
## Workflow Overview
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `ci.yml` | PR opened/synchronized | Quality checks (lint, test, coverage) + molecule tests |
| `auto-merge.yml` | PR labeled `ready-to-merge` | Validates and squash-merges the PR |
| `post-merge.yml` | Push to `master` | Release, wiki sync, badges, Vikunja task update |
| `publish.yml` | Tag push (`v*`) | Build and publish package to PyPI, create Gitea release |
Every change to master goes through a mandatory PR workflow. No exceptions.
## PR Workflow
@@ -60,10 +71,10 @@ Review the full diff (`git diff master...HEAD`) focusing on:
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
Post review comments using `scripts/ci/review_pr.py`:
Post review comments using `devx.ci.pr_review`:
```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
@@ -78,7 +89,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
Once all comments are addressed:
```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event APPROVE \
--body "All comments addressed. LGTM."
```
@@ -96,7 +107,7 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
After the squash-merge:
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/ci/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `devx.ci.post_merge` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
## Branch Protection (Required Gitea Settings)
@@ -131,8 +142,8 @@ The `quality` job in `.gitea/workflows/ci.yml` runs:
1. `make setup` — full environment setup
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
3. `make pytest-cov` — unit tests with 100% coverage enforcement
4. `python3 scripts/check_test_speed.py --max-seconds 10` — verify unit tests run fast
5. `PYTHONPATH=src python3 scripts/ci/release.py --dry-run` — release dry-run validation
4. `python -m devx.tools.check_test_speed --max-seconds 10` — verify unit tests run fast
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation
## Automated Release Pipeline
@@ -144,18 +155,19 @@ After a PR is merged to master, the release pipeline runs automatically.
- Sets up full dev environment (`make setup`) so lint and tests can run
- Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot`
- Runs `scripts/ci/release.py` which uses **git-cliff** to:
- Runs `devx.ci.release` which uses **git-cliff** to:
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only workflow/infrastructure files changed, the release is **skipped entirely** — no version bump, no tag, no publish
- Calculate the next semver version from conventional commits since the last tag
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
- Update `CHANGELOG.md` with the new version section
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **abort immediately** — no commit, no tag
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
- Commit with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents re-triggering post-merge on the release commit)
- Create an annotated tag `vX.Y.Z` on the release commit
- Push both the commit and tag to master
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
### Publish Workflow (`.gitea/workflows/publish.yml`)
@@ -166,25 +178,34 @@ After a PR is merged to master, the release pipeline runs automatically.
- Builds the Python package
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
- Creates a Gitea release with git-cliff-generated release notes
- Uses `scripts/ci/publish.py` for build and publish orchestration
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
- Uses `devx.ci.publish` for build and publish orchestration
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
- Triggers on `pull_request` labeled events
- Runs `scripts/ci/auto_merge.py` with the branch name, PR title, repository, PR number, and label name
- Runs `devx.ci.auto_merge` with the branch name, PR title, repository, PR number, and label name
- Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
- Triggers on push to `master`
- Runs `scripts/ci/post_merge.py` with the latest commit message and commit SHA
- Marks the corresponding Vikunja task as done
- Consolidates release, wiki sync, badge generation, and Vikunja task updates into a single workflow
- **detect-type** — Runs `devx.ci.detect_release_commit` to check if the commit is a release commit (`release: vX.Y.Z`). All subsequent jobs skip for release commits (the `[skip ci]` tag also prevents re-triggering).
- **release** — Runs `devx.ci.release` (see Automated Release Pipeline below)
- **sync-wiki** — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki`
- **badges** — Generates and pushes quality badge SVGs to the `badges` branch via `devx.ci.push_badges`. Runs after the release job (even if release fails or is skipped) so the version badge always reflects the latest state.
- **vikunja** — Marks the corresponding Vikunja task as done via `devx.ci.post_merge`
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project uses
`scripts/ci/classify_changes.py` to classify changed files into two categories:
`devx.ci.classify_changes` to classify changed files into two categories.
**Classification strategy (safe-by-default):** Any file NOT in the explicit
workflow-only 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`.
**User-facing paths** (tool changes → release needed):
- `src/gitea_runner_manager/**` — Python CLI source
@@ -192,28 +213,28 @@ Not all changes require the full CI pipeline or a new release. The project uses
- `pyproject.toml` — Package metadata
**Workflow-only paths** (infrastructure → no release needed):
- `.gitea/workflows/**`, `scripts/**`, `docs/**`, `tests/**`
- `.gitea/workflows/**`, `docs/**`, `tests/**`
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
**CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release workflow**: `release.py` calls `classify_changes.py` to check if any
- **Release workflow**: `release.py` calls `classify_changes` to check if any
user-facing files changed since the last tag. If not, the release is skipped
entirely — no version bump, no tag, no publish.
### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically. The `discover-runners` job runs `scripts/ci/discover_runners.py` which queries the Gitea API for
dynamically. 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 matrix of runner indices. 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.
The `molecule-tests` job uses `fromJSON()` to consume the dynamic
matrix, and passes the runner count to `distribute_molecule.py
matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule
--max-runners` so test pairs are evenly distributed.
When adding or removing Gitea runners:
@@ -223,19 +244,19 @@ When adding or removing Gitea runners:
### Molecule Test Distribution
`scripts/ci/distribute_molecule.py` discovers all molecule scenarios
`devx.molecule.distribute_molecule` discovers all molecule scenarios
under `ansible/roles/*/molecule/` and crosses them with the supported
OS platform matrix (defined in `scripts/ci/platforms.py`), then splits
OS platform matrix (defined in `devx.molecule.platforms`), then splits
the resulting test pairs evenly across the requested number of runners.
Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
`scripts/ci/molecule_ci_guard.py` runs the actual molecule test for a
`devx.molecule.molecule_ci_guard` runs the actual molecule test for a
given test pair, with CI context (Gitea URL, token, run ID) for
reporting results back to the commit status API.
### Commit Message Validation
`scripts/ci/validate_commit_msg.py` validates that commit messages
`devx.ci.validate_commit_msg` validates that commit messages
follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.).
It is used by the pre-commit hook to enforce conventional commits on
feature branches.
@@ -243,7 +264,7 @@ feature branches.
### Release Commit Detection
The `detect-type` job in the post-merge workflow runs
`scripts/ci/detect_release_commit.py` to check whether the latest commit
`devx.ci.detect_release_commit` to check whether the latest commit
is a release commit (format: `release: vX.Y.Z`). When a release commit
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
are skipped — the tag push triggers the publish workflow instead.
@@ -251,9 +272,9 @@ are skipped — the tag push triggers the publish workflow instead.
### Badge Generation and Push
The `badges` job in the post-merge workflow runs
`scripts/ci/push_badges.py` which:
`devx.ci.push_badges` which:
1. Fetches the latest master and hard-resets to it (picks up release commits)
2. Generates quality badge SVG files via `scripts/generate_badges.py`
2. Generates quality badge SVG files via `devx.tools.generate_badges`
3. Creates an orphan `badges` branch
4. Copies SVG files to the branch root
5. Force-pushes the branch to the remote
+134 -6
View File
@@ -9,6 +9,15 @@
- Line length: 120 chars
- 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`)
- No `print()` — use `click.echo()` via `ui.say()` for console output
- No bare `except` — catch specific exceptions
- No `TODO`/`FIXME` comments in committed code
- No functions longer than 50 lines
- No `shell=True` with subprocess
- No `eval()` or `exec()`
- No raw strings in `click.echo()` without `_()` wrapper (i18n)
- No `open()` without `with` statement
- No `Popen()` without cleanup
## Code Style Rules
@@ -16,7 +25,11 @@
- **Line length**: 120 characters
- **Test coverage**: 100% required (`--cov-fail-under=100`)
- **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake + actionlint
- **Formatting**: `ruff format` with double quotes and space indentation
- **Type checking**: `pyright` in strict mode for `src/gitea_runner_manager/`
- **Security scanning**: `bandit -r src/` on every PR
- **Import rules**: `src/gitea_runner_manager/` NEVER imports from devx — the GRM tool is self-contained
## Commit Rules
@@ -26,8 +39,14 @@ Branch commits use conventional commit format (no `GRM-N:` prefix):
feat: add new feature
fix: resolve bug
docs: update README
ci: update workflow
refactor: simplify executor
test: add molecule scenario
chore: update dependencies
```
The pre-commit hook validates that commit messages follow the conventional commit format. Non-conventional commits are rejected.
### Version Bumping Rules
| Commit type | Version bump |
@@ -55,14 +74,104 @@ Every change to master goes through this workflow. No exceptions.
3. **Implement** — write code, tests (100% coverage), update docs
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `devx.ci.pr_review`.
7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
8. **Approve** — post an `APPROVE` review via `devx.ci.pr_review`
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
### 1. Create Vikunja task
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
### 2. Create branch
```bash
git checkout master && git pull
git checkout -b GRM-N-short-description
```
### 3. Implement changes
- Write code following conventions above
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md, docs/ as needed)
### 4. Commit (conventional commits)
Branch commits use conventional commit format (no `GRM-N:` prefix):
```
feat: add new feature
fix: resolve bug
docs: update README
```
### 5. Push and create PR
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
- PR body: summary of changes, `Closes GRM-N`
- Add `ready-to-merge` label **only after review is complete**
### 6. Review the PR
Review the full diff (`git diff master...HEAD`) focusing on:
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
- **Technical excellence**:
- Architecture compliance and evolution
- Single Responsibility Principle (SRP)
- Deduplication (no copy-paste, single source of truth)
- Code smells detection and removal
- Best industry practices
- Industry-grade code quality
- Reusability
- Clean code
- Readability
- Maintainability
- Extensibility
- **Performance**: No unnecessary allocations, O(n) vs O(n^2), efficient data structures
- **Security**: No secrets in logs/process list, input validation, no injection vectors
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
Post review comments using `devx.ci.review_pr`:
```bash
CI_GITEA_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
```
### 7. Address review comments
Fix each comment one by one, commit, and push. Re-review until satisfied.
### 8. Approve and merge
Once all comments are addressed, post an approval review:
```bash
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."
```
Then add the `ready-to-merge` label. The auto-merge workflow will:
1. Validate PR title format and match against Vikunja task title
2. Check that at least one APPROVE review exists
3. Wait for all CI checks to pass
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
> **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.
### Branch Protection (Required Gitea Settings)
Configure the following branch protection rules for `master` in Gitea repo settings:
Branch protection is automatically configured by `devx.tools.configure_repo` (runs as a `configure-repo` job in the post-merge workflow). The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
@@ -74,13 +183,14 @@ The auto-merge workflow enforces the APPROVE review check programmatically as a
## Build & Test Commands
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make setup # Create venv, install deps, set up hooks, install CI tools
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make molecule # All 6 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule
make workflow-check # Static lint + dry-run of workflow YAML
```
## Ansible Role Conventions
@@ -93,6 +203,24 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
- `apt` tasks use `cache_valid_time: 3600` to avoid unnecessary cache updates
- `remove-runner.yml` runs `loginctl disable-linger` and removes subuid/subgid entries
## Change Classification
Not all changes require a new release. The project classifies changes using `devx.ci.classify_changes`:
**Workflow-only paths** (no release needed):
- `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`
- Lint config files, `.env.example`, `.gitignore`
**User-facing paths** (release needed):
- `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`)
- `ansible/**`
- `pyproject.toml`
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.
## Known Issues
+52 -4
View File
@@ -10,7 +10,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`scripts/release.py`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`devx.ci.release`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules)
@@ -22,7 +22,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Each runner instance runs in an isolated rootless Docker environment under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host.
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host. User namespace mapping is configured via `/etc/subuid` and `/etc/subgid` entries (range: 100000-165535). Lingering is enabled so the user's systemd services run without an active login session.
**Source:** `README.md` (Architecture, Features), `AGENTS.md` (Architecture)
@@ -34,7 +34,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
**Rationale:** `scripts/release.py` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
**Rationale:** `devx.ci.release` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation — no manual version bumps are needed.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
@@ -58,7 +58,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`scripts/auto_merge.py`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`devx.ci.auto_merge`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done. Branch protection is automatically configured by `devx.tools.configure_repo`.
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
@@ -73,3 +73,51 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Rationale:** This prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness. Molecule tests are only relevant when Ansible files change. The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`. CI triggers only on `opened` and `synchronize` PR events (not `labeled`) to avoid redundant runs.
**Source:** `AGENTS.md` (CI Path Filtering), `.gitea/workflows/ci.yml` (detect-changes job)
---
## ADR-007: Secrets via Temporary JSON Files (CWE-214)
**Date:** Project inception
**Decision:** Pass secrets (registration tokens, admin API tokens) to Ansible via temporary JSON files with `0600` permissions, never on the command line.
**Rationale:** Passing secrets as command-line arguments (e.g., `--extra-vars '{"token": "..."}'`) makes them visible in the process list (`ps aux`), which is a known security weakness (CWE-214). The `RunnerManager._extra_vars_file()` context manager writes extra-vars to a temporary file via `tempfile.mkstemp()`, sets permissions to `0600`, passes the file to Ansible via `--extra-vars @tempfile`, and deletes the file in a `finally` block — even if an exception occurs. This ensures secrets are never visible in the process list.
**Source:** `AGENTS.md` (Key Conventions), `src/gitea_runner_manager/runner_manager.py` (`_extra_vars_file` method)
---
## ADR-008: Smart CI — User-Facing vs Workflow-Only Change Classification
**Date:** 2026-06-21 (v0.2.0 unreleased)
**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.
**Source:** `AGENTS.md` (Smart CI: User-Facing vs Workflow-Only Changes), `pyproject.toml` (`[tool.devx.classify]`)
---
## ADR-009: devx Package Separation
**Date:** 2026-06-21 (v0.6.2)
**Decision:** Separate CI/CD and development tooling into the `devx` package (installed from git), keeping the GRM tool itself self-contained in `src/gitea_runner_manager/`.
**Rationale:** The GRM CLI tool must be self-contained — it never imports from devx. This ensures the installed package has no dependency on CI infrastructure. devx MAY import from `gitea_runner_manager` (one-way dependency), as it uses the tool's API clients, config, and i18n for CI automation. Cross-module imports within devx are allowed. This separation was formalised when scripts were migrated from the `scripts/` directory to the devx package in GRM-64.
**Source:** `AGENTS.md` (Source Code Separation and devx Integration), `CHANGELOG.md` (0.6.2 — Refactor: "Migrate from scripts/ to devx package")
---
## ADR-010: Dynamic Runner Discovery for Molecule CI
**Date:** 2026-06-21 (v0.5.0+)
**Decision:** Molecule tests are distributed across available Gitea Actions runners dynamically via `devx.molecule.discover_runners`, which queries the Gitea API for runners at all levels (repo, org, instance) and generates a dynamic matrix.
**Rationale:** Hardcoding the number of CI runners would require manual updates when runners are added or removed. Dynamic discovery auto-detects repo/org-level runners via the API. For instance-level runners (which may not be visible without admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable, then to a default of 3. The workflow automatically scales the matrix to match available runners, distributing test pairs evenly.
**Source:** `AGENTS.md` (Dynamic Runner Discovery), `.gitea/workflows/ci.yml` (discover-runners job)
+166 -32
View File
@@ -9,14 +9,20 @@
│ ├── runner_manager.py # Ansible orchestration + registry integration
│ ├── executor.py # Ansible subprocess execution
│ ├── registry.py # Local JSON runner registry
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
│ └── exceptions.py # Custom exceptions
│ ├── 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
│ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files
│ │ ├── templates/ # Jinja2 templates
│ │ └── molecule/ # Test scenarios
│ │ ├── tasks/ # Task files (13 files)
│ │ ├── templates/ # Jinja2 templates (4 files)
│ │ └── molecule/ # Test scenarios (7 scenarios)
│ ├── install-runner.yml # Install playbook
│ ├── update-runner.yml # Update playbook
│ ├── start-runner.yml # Start playbook
@@ -28,38 +34,68 @@
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── .gitea/workflows/ # CI/CD workflows
├── docs/ # Documentation (synced to wiki)
├── Makefile # Build & test automation
└── pyproject.toml # Python project metadata
├── pyproject.toml # Python project metadata
├── cliff.toml # git-cliff configuration
└── .env.example # Environment variable template
```
## Prerequisites
- **Python 3.12+** — Required. The Makefile verifies this before creating the venv. Use `pyenv` to manage Python versions if needed.
- **Git** — For cloning the repository and checking out release tags.
- **Docker** — Only needed for running Molecule tests locally (`make molecule`).
- **Go** — Only needed if you want to install `checkmake` manually (alternatively, `make setup` installs it via `devx.tools.install_checkmake`).
## Setup Development Environment
### Step 1: Clone and checkout latest release
```bash
make setup # Creates venv, installs deps, sets up hooks
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
### Step 2: Ensure Python 3.12+ is available
If you use pyenv:
```bash
pyenv install 3.12
pyenv local 3.12
```
Verify your Python version:
```bash
python3 --version # Must be 3.12 or higher
```
### Step 3: Run make setup
```bash
make setup
source .venv/bin/activate
```
The `make setup` target (from the `Makefile`):
The `make setup` target performs the following:
- Verifies Python 3.12+ is installed
- Creates a virtualenv in `.venv`
- Installs/updates `pip`, `setuptools`, and `wheel`
- Creates `.env` from `.env.example` if not present
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
- Installs `checkmake` via `scripts/install_checkmake.py`
- Runs `scripts/setup.sh` to install dependencies and hooks
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs/updates `pip`, `setuptools`, and `wheel`
4. Creates `.env` from `.env.example` if not present
5. Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
6. Installs the `devx` package from the Oblachno PyPI registry (provides CI/CD tools)
7. Installs `checkmake` via `devx.tools.install_checkmake` (Makefile linter)
8. Installs CI/CD tools via `devx.tools.install_tools` (actionlint, git-cliff, act_runner, tea) to `~/.local/bin`
9. Runs `python -m devx.tools.setup` to install Python dependencies, Ansible Galaxy collections, pre-commit hooks, and configure tea CLI login
### Developer Quick Start
```bash
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
cd gitea-runner-manager
pyenv install 3.12
pyenv local 3.12
make setup
```
### Configure Gitea Credentials
### Step 4: Configure Gitea credentials
```bash
cp .env.example .env
@@ -70,28 +106,49 @@ cp .env.example .env
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
#### Admin API Token (optional)
#### Admin API token (optional)
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
Set `CI_GITEA_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
2. **Systemd user service** is active (proves daemon is polling for jobs)
API checks, if enabled, are purely informational and do not affect pass/fail.
### Step 5: Verify the setup
```bash
grm --version # Should print the version
make lint-all # Should pass with no errors
make pytest-cov # Should pass with 100% coverage
```
## Shell activation scripts
`make setup` generates convenience activation scripts for different shells:
```bash
source activate.sh # bash
source activate.fish # fish
source activate.zsh # zsh
```
These scripts activate the `.venv` virtualenv from the project root.
## Running Linters
```bash
make lint # Python (ruff + pyright + bandit)
make lint # Python (ruff + format check + pyright + bandit)
make lint-bandit # Security scan only
make ansible-lint # Ansible
make makefile-lint # Makefile
make workflow-lint # Gitea Actions workflows (actionlint)
```
The full lint target (`make lint-all`) runs all of the above:
```bash
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
```
Individual lint targets from the `Makefile`:
@@ -101,8 +158,85 @@ Individual lint targets from the `Makefile`:
| `lint-ruff` | `ruff check src/ tests/` |
| `lint-format` | `ruff format --check src/ tests/` |
| `typecheck` | `pyright` |
| `lint-bandit` | `bandit -r src/ scripts/` |
| `lint-bandit` | `bandit -r src/` |
| `lint-deps` | `pip-audit` — checks dependencies for known vulnerabilities |
| `ansible-lint` | `ansible-lint ansible/` |
| `makefile-lint` | `checkmake Makefile` |
| `lint` | ruff + format check + pyright + bandit |
| `lint-all` | lint + ansible-lint + makefile-lint |
| `lint-all` | lint + ansible-lint + makefile-lint + workflow-lint |
## Running Tests
### Unit tests
```bash
make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement
```
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`.
### Integration tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
### Molecule tests
```bash
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
Requires Docker to be installed and running on your machine. Molecule creates Docker containers as test hosts, applies the Ansible role, and verifies the results.
### Full test suite
```bash
make test-all # pytest-cov + molecule
```
## Workflow verification
GRM includes Gitea Actions workflow files in `.gitea/workflows/`. These are verified with two tools:
```bash
make workflow-lint # Static lint via actionlint
make workflow-dryrun # Dry-run via act_runner exec --dryrun
make workflow-check # Both of the above
```
The pre-commit hook runs actionlint automatically when workflow files change.
## Pre-commit hooks
`make setup` installs pre-commit hooks that run:
- **pre-commit**: `ruff check`, `ruff format --check`, conventional commit message validation
- **pre-push**: `make pytest-cov` (ensures tests pass before pushing)
## Make targets reference
| Target | Description |
|--------|-------------|
| `make setup` | Full setup: venv, deps, hooks, CI tools |
| `make setup-ci` | Lean setup for CI jobs (pytest + lint, no Ansible collections) |
| `make setup-quality` | Setup for the quality CI job (lint + test deps) |
| `make setup-molecule` | Full setup for molecule testing |
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea to `~/.local/bin` |
| `make install-devx` | Install the devx package from the Oblachno PyPI registry |
| `make lint-all` | ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
| `make test-unit` | Unit tests without coverage |
| `make test-integration` | Integration tests |
| `make molecule` | All 6 Molecule scenarios on Ubuntu 22.04 |
| `make molecule-all` | All 6 scenarios on all 4 supported OSes |
| `make test-all` | pytest-cov + molecule |
| `make workflow-lint` | Static lint of workflow YAML (actionlint) |
| `make workflow-dryrun` | Dry-run all workflows in Docker |
| `make workflow-check` | workflow-lint + workflow-dryrun |
| `make clean` | Remove `__pycache__`, `.pyc`, `.coverage`, `htmlcov/`, `.molecule/` |
+68 -27
View File
@@ -1,9 +1,12 @@
# Testing Strategy
GRM employs a multi-layered testing strategy: unit tests with 100% coverage enforcement, integration tests for the CLI lifecycle, and Molecule scenarios for Ansible role validation across multiple OS platforms.
## Unit Tests
```bash
make test-unit
make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement
```
Runs pytest with 100% coverage requirement.
@@ -11,9 +14,27 @@ Runs pytest with 100% coverage requirement.
From the `Makefile`:
- `test-unit` — `pytest tests/unit/ -v --no-cov` (unit tests without coverage)
- `pytest-cov` — `pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
- `pytest-cov` — `pytest tests/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required.
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release.
### Test speed verification
The CI quality job also runs `python -m devx.tools.check_test_speed --max-seconds 10` to verify that unit tests run fast (under 10 seconds total). This catches performance regressions early.
## Integration Tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end with a mocked executor boundary. This verifies that the CLI correctly parses arguments, resolves runners from the registry, constructs the right Ansible commands, and handles errors — all without actually connecting to remote hosts.
From the `Makefile`:
- `test-integration` — `pytest tests/integration/ -v --no-cov`
Integration tests are marked with `@pytest.mark.integration` and are not counted toward coverage.
## Molecule Tests
@@ -22,60 +43,80 @@ make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
Runs six scenarios:
Molecule tests validate the Ansible role (`ansible/roles/gitea-runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
- **default** — Rootless Docker runner installation
- **multi-instance** — Two isolated runner instances on the same host
- **lifecycle** — Stop, disable, re-enable, and start sequence
- **template-content** — Verify rendered systemd user service and prune templates
- **deregister** — Runner deregistration
- **update** — Runner binary update
### Scenarios
All scenarios test idempotence (second run produces zero changes).
Seven Molecule scenarios are defined under `ansible/roles/gitea-runner/molecule/`:
| Scenario | Description | What it verifies |
|----------|-------------|------------------|
| `default` | Rootless Docker runner installation | Basic role convergence — user creation, directory structure, binary download, config file, systemd service template, prune timer templates |
| `multi-instance` | Two isolated runner instances on the same host | Two separate converge plays with different runner names; verifies both instances coexist with independent users, data directories, and service files |
| `lifecycle` | Stop, disable, re-enable, and start sequence | Converge, then side_effect stops and disables the service, then re-enables and starts it; verify confirms the service is active again |
| `template-content` | Verify rendered systemd and prune templates | Checks that the systemd user service file contains expected directives (`Type=simple`, `ExecStart`, `Restart=on-failure`, `DOCKER_HOST`, `XDG_RUNTIME_DIR`), and that the prune service and timer templates are correctly rendered |
| `deregister` | Runner deregistration | Creates a fake `.runner` file, then runs the deregister tasks; verifies the `.runner` file is removed |
| `update` | Runner binary update | Converge, then side_effect runs the update playbook; verifies the binary is updated |
| `remove` | Runner removal | Converge, then side_effect runs the remove playbook; verifies the user, directories, and service files are cleaned up |
All scenarios test idempotence (second run produces zero changes), which is a core requirement of the Ansible role.
### Common scenario configuration
All scenarios use `docker_rootless_setup: false` and `skip_runner_registration: true` in their converge playbooks. This is because:
- **Rootless Docker** requires kernel user namespace support, which is not available in all Docker-in-Docker CI environments. The role handles this gracefully via the `docker_rootless_setup` guard.
- **Runner registration** requires a real Gitea instance. The role handles this via the `skip_runner_registration` flag, which skips the `register.yml` and `integration_test.yml` tasks.
### Platforms
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
4 platforms are tested:
The platform list is defined in `scripts/distribute_molecule.py` (single source of truth).
| Platform | Docker image |
|----------|-------------|
| `ubuntu-2204` | `geerlingguy/docker-ubuntu2204-ansible` |
| `ubuntu-2404` | `geerlingguy/docker-ubuntu2404-ansible` |
| `debian-12` | `geerlingguy/docker-debian12-ansible` |
| `archlinux` | `archlinux:latest` |
The platform list is defined in `devx.molecule.platforms` (single source of truth), shared between `devx.molecule.distribute_molecule` (CI) and `devx.molecule.molecule_all` (local dev tool).
### CI Test Distribution
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
CI runs all 6 scenarios x 4 platforms (24 test pairs) distributed across available Gitea Actions runners.
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `scripts/distribute_molecule.py --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `scripts/molecule_ci_guard.py` with those pairs.
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.
## Integration Tests
The `molecule-tests` job uses `fromJSON()` to consume the dynamic matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule --max-runners` so test pairs are evenly distributed.
```bash
make test-integration
```
`devx.molecule.distribute_molecule` discovers all molecule scenarios under `ansible/roles/*/molecule/` and crosses them with the supported OS platform matrix, then splits the resulting test pairs evenly across the requested number of runners. Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
`devx.molecule.molecule_ci_guard` runs the actual molecule test for a given test pair, with CI context (Gitea URL, token, run ID) for reporting results back to the commit status API.
From the `Makefile`:
### Path-based CI filtering
- `test-integration` — `pytest tests/integration/ -v --no-cov`
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 (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
## Full Test Suite
```bash
make test-all # Runs unit tests + linters + molecule
make test-all # Runs pytest-cov + molecule (Ubuntu 22.04)
```
From the `Makefile`:
- `test-all` — `pytest-cov + molecule` (unit tests with coverage + all 6 molecule scenarios on Ubuntu 22.04)
For a complete test across all platforms, use `make molecule-all` separately.
## Build & Test Commands Summary
From `AGENTS.md`:
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make setup # Create venv, install deps, set up hooks, install CI tools
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make test-integration # Integration tests
make molecule # All 6 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule
+71 -1
View File
@@ -2,6 +2,32 @@
GRM provides the following CLI commands for managing Gitea Actions runners. The base command is `grm`.
## Command Summary
| Command | Arguments | Description |
|---------|-----------|-------------|
| `grm install` | `<host>` | Install and configure a runner on a remote host |
| `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 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 --version` | — | Show the installed version |
### Common lifecycle options
The `start`, `stop`, `enable`, `status`, `disable`, and `remove` commands all accept these override options. By default, connection details are read from the local registry (`~/.local/share/grm/runners.json`).
| 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 |
## install
Install and configure a Gitea Runner on a remote host.
@@ -25,7 +51,7 @@ grm install <host> [options]
| `--name` | `-n` | hostname | Gitea Runner name |
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
| `--url` | — | `GITEA_URL` env | Gitea URL |
| `--admin-token` | `-a` | `REPO_TOKEN` env | Gitea admin API token for integration test |
| `--admin-token` | `-a` | `CI_GITEA_TOKEN` env | Gitea admin API token for integration test |
| `--integration-retries` | `-r` | `3` (`GITEA_INTEGRATION_RETRIES` env) | Integration test API retries |
| `--labels` | `-l` | `GITEA_RUNNER_LABELS` env | Runner labels for Gitea Actions. Example: `docker:docker://alpine:latest` |
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
@@ -231,3 +257,47 @@ grm list
```
This command takes no arguments or options. It displays a table with columns: NAME, HOST, USER, LABELS, STATUS for all runners stored in the local registry at `~/.local/share/grm/runners.json`.
The status is checked live by running an Ansible ad-hoc command on each remote host (`systemctl --user is-active gitea-runner`). Possible status values: `active`, `inactive`, `failed`, `unknown`.
**Example output:**
```
NAME HOST USER LABELS STATUS
------------------------------------------------------------------------------------------
prod-runner 192.168.1.10 ubuntu docker:docker://gitea/... active
build-runner 192.168.1.10 ubuntu docker:docker://gitea/... active
test-runner 192.168.1.20 ubuntu inactive
```
If no runners are registered:
```
No runners registered. Use 'grm install' to add one.
```
## --version
Show the installed GRM version.
```bash
grm --version
```
This reports the version from `__version__` in `src/gitea_runner_manager/__init__.py`, which is the single source of truth set by the automated release pipeline.
## Environment Variables
All CLI options can be set via environment variables (loaded from `.env` via python-dotenv). Command-line flags take precedence over environment variables.
| Variable | Used by | Description |
|----------|---------|-------------|
| `GITEA_URL` | `install`, `disable`, `remove` | Gitea instance URL |
| `GITEA_REGISTRATION_TOKEN` | `install`, `disable`, `remove` | Runner registration token |
| `CI_GITEA_TOKEN` | `install` | Admin API token for integration test |
| `GITEA_INTEGRATION_RETRIES` | `install` | API check retries (default: 3) |
| `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_LOG_LEVEL` | all | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
+137 -3
View File
@@ -10,9 +10,9 @@ 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 REPO_TOKEN and do I need it?
### What is the CI_GITEA_TOKEN and do I need it?
`REPO_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.
`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.
To generate one: Settings → Applications → Generate New Token, with the `admin` scope (or at minimum `read:user`, `read:repository`, `read:admin`).
@@ -22,18 +22,152 @@ If you skip it, GRM will still verify the runner correctly — it just won't sho
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
On the remote host, add a sudoers entry:
```bash
echo "ubuntu ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/grm
```
Then use:
```bash
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?
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.
```bash
grm install 192.168.1.10 --user ubuntu --name workflow-runner
grm install 192.168.1.10 --user ubuntu --name build-runner
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?
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**.
Common causes:
- Registration token expired — generate a new one from Gitea
- Network connectivity issue between the runner host and Gitea
- 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?
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?
GRM stores each runner's connection details (host, user, SSH key, Gitea URL) 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.
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?
Use the `grm update` command:
```bash
grm update 192.168.1.10 --user ubuntu
```
To update to a specific version:
```bash
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?
Use the `grm remove` command:
```bash
grm remove prod-runner --token <registration-token>
```
This deregisters the runner from Gitea, stops and disables the systemd service, removes the system user, deletes data and config directories, removes subuid/subgid entries, disables lingering, and removes the entry from the local registry.
If the remote host is already gone or unreachable, use `--force` to skip remote cleanup and only remove the local registry entry:
```bash
grm remove prod-runner --force
```
### 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?
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?
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese).
```bash
GRM_LANG=bg grm install 192.168.1.10 --user ubuntu --name prod-runner
```
Or set it in your `.env` file:
```bash
GRM_LANG=bg
```
### How do I enable debug logging?
Set the `GRM_LOG_LEVEL` environment variable to `DEBUG`:
```bash
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?
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.
Use an official Gitea runner image with Node.js, Python, and Docker CLI. Avoid bare OS images like `alpine:latest` because `actions/checkout@v4` needs Node.js.
Example:
```bash
grm install 192.168.1.10 --user ubuntu --name prod-runner \
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
```
### Is GRM secure?
Yes. GRM is designed with security as a first-class concern:
- **Rootless Docker**: Each runner operates under a dedicated unprivileged system user. Containers never have root access to the host.
- **Secret handling**: Registration tokens are passed via temporary JSON files with `0600` permissions, never on the command line (CWE-214).
- **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?
Yes:
```bash
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?
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:
- User creation uses `ansible.builtin.user` which only creates if the user doesn't exist
- Package installation uses `state: present` which only installs if not already installed
- Template creation uses `ansible.builtin.template` which only writes if the content changed
- Rootless Docker setup uses `creates:` to skip if already configured
This makes GRM safe for CI/CD pipelines and configuration management.
+126 -26
View File
@@ -1,23 +1,44 @@
# Getting Started
## Developer Setup
This guide walks you through setting up GRM, configuring Gitea credentials, and installing your first runner.
## Prerequisites
Before you begin, ensure you have:
- **Python 3.12+** on your local machine
- **SSH access** to the target host(s) where runners will be installed
- **Sudo privileges** on the target host(s) for the SSH user
- **A Gitea instance** with admin access to create registration tokens
- **Git** for cloning the repository
## Step 1: Clone and Setup
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
make setup
source .venv/bin/activate
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The `git describe --tags --abbrev=0` command automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
`make setup` creates a virtualenv, installs all dependencies (including Ansible), creates `.env` from `.env.example`, and sets up pre-commit hooks.
If you use pyenv for Python version management:
```bash
pyenv install 3.12
pyenv local 3.12
make setup
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The `git describe --tags --abbrev=0` command automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
## Configure Gitea Credentials
## Step 2: Configure Gitea Credentials
GRM needs two tokens from your Gitea instance: a **registration token** (required) and an **admin API token** (optional, for post-install verification).
### Step 1: Get the Registration Token
### Get the Registration Token
The registration token tells Gitea to accept the runner when it connects.
@@ -33,7 +54,7 @@ The registration token tells Gitea to accept the runner when it connects.
>
> Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
### Step 2: Get the Admin API Token (optional)
### Get the Admin API Token (optional)
The admin API token enables post-install API checks that verify the runner appears in Gitea's runner list. This is purely informational — the integration test primarily verifies the runner by checking:
@@ -47,7 +68,7 @@ To get an admin API token:
3. Select the **admin** scope (or at minimum: `read:user`, `read:repository`, `read:admin`)
4. Click **Generate Token** and copy it immediately (it won't be shown again)
### Step 3: Create the `.env` File
### Create the `.env` File
```bash
cp .env.example .env
@@ -63,23 +84,24 @@ GITEA_URL=https://git.example.com
GITEA_REGISTRATION_TOKEN=GRxxxxxxxxxxxxxxxxxx
# Admin API token from Step 2 (optional)
REPO_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CI_GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `GITEA_URL` | Yes | Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Yes | Runner registration token from Gitea admin panel |
| `REPO_TOKEN` | No | Admin API token for post-install verification |
| `GITEA_INTEGRATION_RETRIES` | No | API check retries (default: 3) |
| `GITEA_RUNNER_USER` | No | 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 | UI language: `en`, `bg`, `de`, `ru`, `zh` (default: `en`) |
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GITEA_URL` | Yes | — | Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Yes | — | Runner registration token from Gitea admin panel |
| `CI_GITEA_TOKEN` | No | — | Admin API token for post-install verification |
| `GITEA_INTEGRATION_RETRIES` | No | `3` | API check retries (default: 3) |
| `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_LOG_LEVEL` | No | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
## Install a Runner
## Step 3: Install Your First Runner
Using the CLI (you will be prompted for the sudo password by default):
@@ -87,6 +109,19 @@ Using the CLI (you will be prompted for the sudo password by default):
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
This command:
1. Connects to `192.168.1.10` via SSH as user `ubuntu` using the specified key
2. Creates a dedicated system user `grm-prod-runner` with lingering enabled
3. Installs Docker in rootless mode under the `grm-prod-runner` user
4. Downloads and installs the gitea_runner binary
5. Creates the runner configuration file at `/etc/gitea-runner/prod-runner/config.yaml`
6. Registers the runner with your Gitea instance
7. Creates and starts a systemd user service (`gitea-runner.service`)
8. Sets up a Docker prune timer (daily cleanup)
9. Runs an integration test to verify the installation
10. Saves the runner to the local registry at `~/.local/share/grm/runners.json`
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
Using Make:
@@ -95,7 +130,18 @@ Using Make:
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
```
## Verify Runner
### Runner labels
By default, runners are registered with the label `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels`:
```bash
grm install 192.168.1.10 --user ubuntu --name prod-runner \
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
```
> **Note:** Use an official Gitea runner image with Node.js, Python, and Docker CLI. Avoid bare OS images like `alpine:latest` because `actions/checkout@v4` needs Node.js.
## Step 4: Verify the Installation
The installer performs an automated integration test that verifies:
@@ -104,11 +150,43 @@ The installer performs an automated integration test that verifies:
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
Optional: If `REPO_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
Optional: If `CI_GITEA_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
### Check runner status via CLI
```bash
grm status prod-runner
```
This connects to the remote host and checks the systemd user service status.
### List all runners
```bash
grm list
```
This displays a table with columns: NAME, HOST, USER, LABELS, STATUS for all runners in the local registry. The status is checked live via an Ansible ad-hoc command.
## Step 5: Manage the Runner Lifecycle
Once installed, you can manage the runner by name (connection details are stored in the local registry):
```bash
grm stop prod-runner # Stop the runner service
grm start prod-runner # Start the runner service
grm enable prod-runner # Enable the runner to start on boot
grm status prod-runner # Check the runner status
grm update 192.168.1.10 --user ubuntu # Update the runner binary
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.
## View Logs
**GRM application logs** (Python CLI output):
### GRM application logs (on your local machine)
```bash
# Application log file (all messages including DEBUG)
@@ -118,20 +196,42 @@ cat ~/.local/state/grm/logs/grm.log
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
**Runner logs** (on the remote host):
### Ansible execution logs
Each `grm` command that invokes Ansible creates a timestamped log file:
```bash
ls ~/.local/state/grm/logs/ansible-*.log
cat ~/.local/state/grm/logs/ansible-20260622-143012.log
```
### Runner logs (on the remote host)
```bash
# Runner logs (via systemd user service)
sudo -u grm-<name> journalctl --user -u gitea-runner -f
sudo -u grm-prod-runner journalctl --user -u gitea-runner -f
# Rootless Docker daemon logs
sudo -u grm-prod-runner journalctl --user -u docker -f
```
The GRM application writes to two destinations:
### Logging destinations
The GRM application writes to three destinations:
| Destination | Level | Content |
|-------------|-------|---------|
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
| `~/.local/state/grm/logs/ansible-<timestamp>.log` | — | Full Ansible playbook output per execution |
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
Console output is automatically colorised via `click.style`: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
## Next Steps
- **Install more runners** on the same or different hosts — see [Installation](Installation)
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands.-)
- **Troubleshoot issues** — see [Troubleshooting](Troubleshooting)
- **Understand the architecture** — see [Architecture](Architecture)
+192 -8
View File
@@ -1,20 +1,102 @@
# Installation
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/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
- **SSH key authentication** — 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.
### On your local machine (where you run `grm`)
- **Python 3.12+** — GRM targets Python 3.12 and requires it for development setup. Use `pyenv` if you need to manage multiple Python versions.
- **Ansible** — Installed automatically by `make setup` (via pip). GRM delegates all remote operations to `ansible-playbook`.
- **SSH key** — A private key that grants access to the target host(s) as a user with sudo privileges.
### On the target host(s) (where runners will be installed)
- **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](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/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.
## Supported Operating Systems
- Arch Linux
- Ubuntu 22.04 / 24.04
- Debian 12
| OS | Versions | Package manager |
|----|----------|-----------------|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via molecule scenarios on every PR.
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files. The platform matrix is defined in `devx.molecule.platforms` as the single source of truth.
## Installation Methods
### Method 1: From source (recommended for full control)
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
```
`make setup` performs the following:
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs all Python dependencies (including Ansible, Click, python-dotenv)
4. Creates `.env` from `.env.example` if not present
5. Installs development tools (actionlint, git-cliff, act_runner, checkmake)
6. Sets up pre-commit hooks
### Method 2: Via pip
GRM is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
**Quick install (one-off):**
```bash
pip install gitea-runner-manager --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
**Persistent configuration (recommended):**
Add the registry to `~/.pip/pip.conf`:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then install normally:
```bash
pip install gitea-runner-manager
```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks
and role are bundled with the package, so `grm install` works out of the box.
For development or access to Make targets, clone the repository (Method 1).
### Post-install configuration
After installation, create your `.env` file:
```bash
cp .env.example .env
# Edit .env with your Gitea URL and registration token
```
Required variables:
| Variable | Description |
|----------|-------------|
| `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.
## Quick Start Install
@@ -34,13 +116,63 @@ Using Make:
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
```
The Make target wraps the `grm install` CLI command. All Make install variables are optional except `HOST`:
| Variable | Description |
|----------|-------------|
| `HOST` | Remote host (IP address or hostname) — **required** |
| `USER` | SSH user |
| `KEY` | Path to SSH private key |
| `NAME` | Gitea Runner name |
| `TOKEN` | Registration token |
| `ASK_BECOME_PASS` | Set to `1` to prompt for sudo password |
## What Gets Installed on the Target Host
When you run `grm install`, the Ansible role creates the following on the remote host:
| Resource | Path | Description |
|----------|------|-------------|
| System user | `grm-<name>` | Dedicated system user with `/bin/bash` shell |
| Home directory | `/home/grm-<name>/` | User home with `.config/systemd/user/` |
| Data directory | `/var/lib/gitea-runner/<name>/` | Runner data including `.runner` registration file |
| Config directory | `/etc/gitea-runner/<name>/` | Runner configuration file (`config.yaml`) |
| Runner binary | `/usr/local/bin/gitea_runner` | The gitea_runner executable |
| Docker socket | `/run/user/<UID>/docker.sock` | Rootless Docker socket |
| Systemd service | `gitea-runner.service` | User service for the runner daemon |
| Docker prune timer | `docker-prune.timer` | Daily Docker cleanup timer |
| subuid/subgid | `/etc/subuid`, `/etc/subgid` | User namespace mapping (100000-165535) |
| Lingering | `loginctl enable-linger` | Ensures services run without active login |
## Runner Registry
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
After installation, 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`. This means you rarely need to repeat connection arguments:
```bash
# List all registered runners with live systemd status
grm list
# Manage runners by name — connection details come from the registry
grm status prod-runner
grm stop prod-runner
grm start prod-runner
```
You can override any stored value by passing the corresponding flag (`--host`, `--user`, `--key`).
### Registry file format
```json
{
"prod-runner": {
"host": "192.168.1.10",
"user": "ubuntu",
"key": "/home/user/.ssh/id_ed25519",
"gitea_url": "https://git.example.com",
"labels": "docker:docker://gitea/runner-images:ubuntu-latest",
"created_at": "2026-06-22T14:30:12.000000+00:00"
}
}
```
## Multiple Instances on the Same Host
@@ -55,4 +187,56 @@ grm install 192.168.1.10 --user ubuntu --name build-runner
# Manage them independently by name
grm stop workflow-runner
grm status build-runner
grm list
```
Each instance gets:
- **Dedicated system user**: `grm-<name>` with its own home directory
- **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
- **Data directory**: `/var/lib/gitea-runner/<name>/`
- **Config directory**: `/etc/gitea-runner/<name>/`
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
- **Docker prune timer**: Per-instance daily cleanup
Runners on the same host never interfere with each other or with the host's Docker installation.
## Updating Runners
To update the gitea_runner binary on a remote host:
```bash
grm update 192.168.1.10 --user ubuntu
```
To update to a specific version:
```bash
grm update 192.168.1.10 --user ubuntu --version 1.0.8
```
Using Make:
```bash
make update HOST=192.168.1.10 USER=ubuntu VERSION=1.0.8
```
## Removing Runners
To remove a runner completely (deregisters from Gitea, removes user, directories, and service files):
```bash
grm remove prod-runner --token <registration-token>
```
To skip remote cleanup and only remove the local registry entry (useful when the remote host is already gone):
```bash
grm remove prod-runner --force
```
Using Make:
```bash
make remove NAME=prod-runner TOKEN=<registration-token>
```
+162 -15
View File
@@ -1,42 +1,189 @@
# Troubleshooting
## "Event loop is closed" warning
## Installation Issues
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
### Ansible connection fails (UNREACHABLE)
## Runner appears offline after installation
**Symptom:** Ansible reports `UNREACHABLE` when trying to connect to the target host.
**Causes and solutions:**
- **SSH key not found or wrong path** — Verify the key path with `--key`. The key must be readable by the user running `grm`.
- **SSH user does not exist on the target** — Verify the `--user` argument. The user must exist on the remote host and have sudo privileges.
- **Host is not reachable** — Verify the host IP/hostname with `ping` and `ssh -u <user> <host>`.
- **SSH port is not 22** — GRM uses the default SSH port. If your host uses a different port, you may need to configure SSH config (`~/.ssh/config`) with the appropriate port.
### Sudo password prompt fails or is not displayed
**Symptom:** The sudo password prompt does not appear or the command hangs.
**Causes and solutions:**
- **Non-interactive session** — If running in a CI/CD pipeline or script without a TTY, the password prompt cannot be displayed. Configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
- **Wrong sudo password** — Ensure you are entering the correct sudo password for the remote user.
### GITEA_URL must be set
**Symptom:** Error message `GITEA_URL must be set (or pass --url)`.
**Solution:** Set `GITEA_URL` in your `.env` file or pass it via `--url`:
```bash
# In .env
GITEA_URL=https://git.example.com
# Or on the command line
grm install 192.168.1.10 --user ubuntu --url https://git.example.com
```
### GITEA_REGISTRATION_TOKEN must be set
**Symptom:** Error message `GITEA_REGISTRATION_TOKEN must be set (or pass --token)`.
**Solution:** Set `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token`. The token must be a valid registration token from your Gitea instance (it starts with `GR`).
## Runner Issues
### Runner appears offline after installation
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
- Verify the registration token has not expired — generate a new one from Gitea if needed (Site Administration → Actions → Runners → Create Registration Token).
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Check logs for registration errors.
- Check logs for registration errors: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
- Confirm the runner appears in the Gitea UI under **Actions → Runners**. If it shows as offline, the runner daemon may not be polling — check network connectivity between the runner host and Gitea.
## Integration test fails
### Runner service fails to start
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`
- Verify the runner binary exists: `ls -la /usr/local/bin/gitea_runner`
- Verify the config file exists: `ls -la /etc/gitea-runner/<name>/config.yaml`
- Verify the `.runner` registration file exists: `ls -la /var/lib/gitea-runner/<name>/.runner`
### Rootless Docker: service fails to start
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`. If not enabled, run `sudo loginctl enable-linger grm-<name>`.
- Verify subuid/subgid entries exist: `grep grm-<name> /etc/subuid /etc/subgid`. If missing, the rootless Docker setup will fail.
### Runner not found in registry
**Symptom:** Error message `Runner '<name>' not found in registry. Use 'grm install' first or provide --host and --user.`
**Solution:** The runner was not installed via `grm install`, or the registry file was deleted. Either:
1. Install the runner first: `grm install <host> --user <user> --name <name>`
2. Or provide explicit connection details: `grm status <name> --host <host> --user <user>`
## Integration Test Issues
### Integration test fails
The test checks two things:
1. **`.runner` file missing or invalid** — Registration failed. Check:
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
- The registration token is valid and has not expired
- Runner logs for registration errors
- Runner logs for registration errors: `sudo -u grm-<name> journalctl --user -u gitea-runner`
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
- The `.runner` file should contain valid JSON with `id`, `uuid`, `token`, `address` fields
2. **Service not running** — Daemon failed to start. Check:
- `sudo -u grm-<name> systemctl --user status gitea-runner`
- Logs for connection errors
- Logs for connection errors: `sudo -u grm-<name> journalctl --user -u gitea-runner`
- Verify the rootless Docker daemon is running (see above)
## Rootless Docker: service fails to start
### API verification shows error status
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
If `CI_GITEA_TOKEN` is set, the integration test queries the Gitea API. If the API returns `401` or `403`, the token does not have sufficient permissions. This is **informational only** and does not affect pass/fail. The test passes as long as the `.runner` file exists and the systemd service is active.
## Logging and Diagnostics
### Enable debug logging
```bash
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
This prints all debug messages to the console. The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting.
### View Ansible execution logs
Each `grm` command that invokes Ansible creates a timestamped log file:
```bash
ls ~/.local/state/grm/logs/ansible-*.log
cat ~/.local/state/grm/logs/ansible-<timestamp>.log
```
These logs contain the full Ansible output, including task results, changed/failed counts, and any error messages.
### View runner logs on the remote host
```bash
# Runner daemon logs
sudo -u grm-<name> journalctl --user -u gitea-runner -f
# Rootless Docker daemon logs
sudo -u grm-<name> journalctl --user -u docker -f
# Docker prune timer logs
sudo -u grm-<name> journalctl --user -u docker-prune.service
```
## Development Issues
### "Event loop is closed" warning
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
### Pre-commit rejects commit message
The pre-commit hook validates that commit messages follow conventional commit format (`feat:`, `fix:`, `docs:`, etc.). The `GRM-N:` prefix is not allowed on branch commits — use it only in PR titles.
**Correct:**
```
feat: add new runner label option
```
**Incorrect:**
```
GRM-33: add new runner label option
update README
```
### `make pytest-cov` fails with coverage below 100%
Add tests for any new code paths. The coverage requirement is strict (`--cov-fail-under=100`). Run `make pytest-cov` locally to see which lines are not covered:
```bash
make pytest-cov
# The output shows "Missing" lines for each file
```
### `make molecule` fails with Docker not available
Molecule requires Docker to be installed and running on your machine. Verify:
```bash
docker info # Should print Docker server info
```
If Docker is not installed, install it via your package manager or [Docker's official installation guide](https://docs.docker.com/get-docker/).
## Common Issues Reference Table
| Symptom | Likely Cause | Solution |
|---------|-------------|----------|
| Ansible UNREACHABLE | SSH connection failed | Verify `--user`, `--key`, and host reachability |
| `GITEA_URL must be set` | Missing environment variable | Set `GITEA_URL` in `.env` or pass `--url` |
| `GITEA_REGISTRATION_TOKEN must be set` | Missing environment variable | Set `GITEA_REGISTRATION_TOKEN` in `.env` or pass `--token` |
| Runner appears offline | Registration failed or service not running | Check GITEA_URL, token validity, and service status |
| Rootless Docker fails to start | subuid/subgid missing or lingering disabled | Verify `/etc/subuid`, `/etc/subgid`, and `loginctl show-user` |
| Runner not found in registry | Runner not installed or registry deleted | Run `grm install` or provide `--host` and `--user` |
| 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 |
@@ -44,8 +191,8 @@ The test checks two things:
| 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 |
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
| `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` |
+4 -2
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env bash
# pre-commit hook: fail if unit tests take longer than 2 seconds.
# pre-commit hook: fail if unit tests are too slow.
# Checks both total suite time (10s) and per-test time (0.5s).
# Aligned with CI (ci.yml uses same thresholds).
set -e
python3 scripts/check_test_speed.py
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
+60 -3
View File
@@ -1,4 +1,61 @@
#!/usr/bin/env bash
# pre-push hook: fail if unit tests take longer than 2 seconds.
set -e
python3 scripts/check_test_speed.py
# pre-push hook: validate Vikunja task exists and tests are fast.
#
# This catches issues that would otherwise only surface in CI:
# - Branch name missing task ID (e.g., GRM-N)
# - Vikunja task does not exist for the task ID in the branch name
# - Unit tests too slow (total > 4s, per-test > 0.5s)
#
# Uses devx.tools.pre_push_check for reusable validation logic.
# Project config (task prefix, Vikunja project ID) is read from
# [tool.devx] in pyproject.toml by devx.config — no hardcoded values here.
#
# Bootstrap resilience: if devx is not importable (e.g., during devx
# upgrades), the hook prints a warning and allows the push.
# Determine the branch being pushed
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [ -z "$BRANCH" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "main" ]; then
exit 0
fi
# Load .env if present (for VIKUNJA_TOKEN)
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. .env
set +a
fi
# Find the Python interpreter with devx installed
if [ -f .venv/bin/python ]; then
PY=.venv/bin/python
elif [ -x "${HOME}/.pyenv/bin/pyenv" ]; then
export PYENV_ROOT="${HOME}/.pyenv"
export PATH="${PYENV_ROOT}/bin:${PYENV_ROOT}/shims:${PATH}"
eval "$("${PYENV_ROOT}/bin/pyenv" init -)" 2>/dev/null || true
eval "$("${PYENV_ROOT}/bin/pyenv" virtualenv-init -)" 2>/dev/null || true
pyenv activate gitea-runner-manager 2>/dev/null || true
PY=python3
else
PY=python3
fi
# Bootstrap resilience: if devx is not importable, warn but allow the push
if ! $PY -c "import devx.tools.pre_push_check" 2>/dev/null; then
echo "WARNING: devx not installed — pre-push check skipped."
echo "Run 'make setup' to install devx."
exit 0
fi
# Run pre-push validation via devx
$PY -m devx.tools.pre_push_check --branch "$BRANCH" || {
echo ""
echo "Pre-push validation failed. Fix the issues above before pushing."
echo "To bypass (NOT recommended): git push --no-verify"
exit 1
}
# Check test speed (aligned with CI thresholds)
$PY -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
+78 -8
View File
@@ -27,17 +27,35 @@ grm = "gitea_runner_manager.cli:cli"
version = {attr = "gitea_runner_manager.__version__"}
[project.optional-dependencies]
dev = [
# Minimal deps for CI scripts that only need click/dotenv/requests
# (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",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx>=0.23.0",
]
# Lint and type-checking tools (quality job)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"ansible-lint>=26.4.0",
"bandit>=1.8.2",
"pip-audit>=2.10",
"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",
]
# 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.23.0",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
@@ -49,9 +67,9 @@ where = ["src"]
gitea_runner_manager = ["translations.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src", "."]
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
testpaths = ["tests", "scripts/tests"]
pythonpath = ["src", "scripts"]
addopts = "--cov=src/gitea_runner_manager --cov=scripts/prune_runner_images.py --cov-report=term-missing --cov-fail-under=100"
markers = [
"integration: marks tests as integration tests (not counted in coverage)",
]
@@ -69,6 +87,58 @@ quote-style = "double"
indent-style = "space"
[tool.pyright]
include = ["src", "scripts"]
include = ["src"]
pythonVersion = "3.12"
strict = ["src/gitea_runner_manager"]
# ---------------------------------------------------------------------------
# Change classification — determines which changes trigger a release
# ---------------------------------------------------------------------------
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
# Project-specific devx configuration (read by devx.config)
[tool.devx]
task_prefix = "GRM"
vikunja_project_id = 6
repo_owner = "oblachno-oss"
repo_name = "gitea-runner-manager"
# Molecule test weights for LPT scheduling.
# GRM has a single role (gitea-runner) with 7 scenarios.
# Weights are estimates — recalibrate from CI logs after next run.
[tool.devx.molecule.weights]
"multi-instance" = 8
"lifecycle" = 6
"update" = 5
"default" = 4
"deregister" = 3
"remove" = 3
"template-content" = 2
# lint config, etc.) that applies to any Python project. We only specify
# what's different about GRM.
[tool.devx.classify]
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
# Project-specific infrastructure paths (merged with defaults).
# scripts/** — dev tools and CI/CD automation, not part of the installed package.
# (scripts were migrated to devx in GRM-64, but the path is kept for
# historical correctness and in case scripts are added back.)
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
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure)
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
"""Run unit tests and enforce a maximum execution-time budget.
Usage:
python3 scripts/check_test_speed.py [--max-seconds N]
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import click
from gitea_runner_manager.i18n import _
DEFAULT_MAX_SECONDS = 2.0
TEST_COMMAND = ["make", "test-unit"]
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
def run_tests() -> tuple[str, str]:
"""Execute the unit-test suite and return (stdout, stderr)."""
result = subprocess.run( # nosec B603
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
return result.stdout, result.stderr
def parse_duration(output: str) -> float:
"""Extract elapsed seconds from pytest summary line.
Raises:
click.ClickException: when the timing line cannot be found.
"""
for line in output.splitlines():
match = _TIMING_RE.search(line)
if match:
return float(match.group(2))
raise click.ClickException(_("Could not parse test execution time from output."))
def check_speed(duration: float, max_seconds: float) -> None:
"""Validate duration is within budget; raise on violation."""
if duration > max_seconds:
raise click.ClickException(
_(
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
duration=duration,
max=max_seconds,
)
)
def main(max_seconds: float) -> None:
"""Run tests, parse timing, and enforce the budget."""
stdout, stderr = run_tests()
combined = stdout + "\n" + stderr
click.echo(combined, err=False)
duration = parse_duration(combined)
check_speed(duration, max_seconds)
click.echo(
_(
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
duration=duration,
max=max_seconds,
)
)
@click.command()
@click.option(
"--max-seconds",
type=float,
default=DEFAULT_MAX_SECONDS,
show_default=True,
help="Maximum allowed execution time in seconds.",
)
def cli(max_seconds: float) -> None:
main(max_seconds)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
-353
View File
@@ -1,353 +0,0 @@
#!/usr/bin/env python3
"""Auto-merge PR by extracting task ID from branch and constructing merge title.
Waits for CI checks to complete before attempting the merge.
PR title format: ``GRM-N: <vikunja task title>``
Merge commit format: ``GRM-N <conventional commit message>``
The conventional commit message is taken from the first commit on the PR
branch (the branch HEAD). This allows the PR title to be a human-friendly
Vikunja task title while the squashed commit follows conventional commits.
Usage:
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
"""
import os
import re
import subprocess # nosec B404
import time
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient
from gitea_runner_manager.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
GITEA_API_URL,
TASK_ID_RE,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
READY_TO_MERGE = "ready-to-merge"
MAX_WAIT_SECONDS = 600 # 10 minutes max — CI may still be running when label is added
POLL_INTERVAL_SECONDS = 15 # Poll every 15 seconds
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() or result.stdout.strip(),
)
)
return result
# PR title: GRM-N: <vikunja task title>
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
load_dotenv(override=True)
def extract_task_id(branch: str) -> str:
"""Extract GRM-N task identifier from branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def validate_pr_title(pr_title: str, task_id: str) -> None:
"""Raise ClickException if PR title does not follow the required format.
Expected: ``GRM-N: <vikunja task title>``
"""
if not PR_TITLE_RE.match(pr_title):
raise click.ClickException(
_(
"Oops! PR title must follow format 'GRM-N: <task title>'.\n"
" Expected: {task_id}: <task title>\n"
" Got: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
if not pr_title.startswith(f"{task_id}:"):
raise click.ClickException(
_(
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given GRM-N identifier.
Returns empty string if VIKUNJA_TOKEN is not set (skip validation).
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return ""
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return ""
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
"""Validate that PR title matches the Vikunja task title.
Skips validation if VIKUNJA_TOKEN is not set.
"""
vikunja_title = get_vikunja_task_title(task_id)
if not vikunja_title:
click.echo(_("Warning: could not fetch Vikunja task title, skipping title match validation."))
return
expected = f"{task_id}: {vikunja_title}"
if pr_title != expected:
raise click.ClickException(
_(
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
expected=expected,
pr_title=pr_title,
)
)
def has_approval_review(client: GiteaClient, pr_number: str) -> bool:
"""Check whether the PR has at least one substantive APPROVE review.
A substantive review has a body longer than 20 characters (not just
"LGTM" or "OK"). This ensures the reviewer actually reviewed the PR
rather than rubber-stamping it.
Falls back to checking that no REQUEST_CHANGES reviews are pending
when self-approval is not possible (single-token workflow).
"""
reviews = client.get_pr_reviews(pr_number)
has_approved = False
has_changes_requested = False
for r in reviews:
state = r.get("state", "")
if state == "APPROVED":
body = str(r.get("body", "")).strip()
if len(body) > 20 or r.get("comments", []):
has_approved = True
elif state == "REQUEST_CHANGES":
has_changes_requested = True
if has_approved:
return True
# In single-token workflows, self-approval is not allowed.
# Allow merge if no changes are requested (the automated pr-review
# job and CI quality gate serve as the review enforcement).
if not has_changes_requested:
click.echo(
_("No APPROVE review found, but no REQUEST_CHANGES either. Proceeding (single-token workflow fallback).")
)
return True
return False
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits.
Iterates commits in reverse order (newest first) to find the first
message matching the conventional commit format. Falls back to the
newest commit message if none match.
"""
for commit in reversed(commits):
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
if CONVENTIONAL_RE.match(message):
return message
# Fallback: use the newest commit's first line
if commits:
commit_info = commits[-1].get("commit", {})
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return ""
def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
"""Check whether the PR has the ready-to-merge label via the API."""
labels = client.get_pr_labels(pr_number)
return any(label.get("name") == READY_TO_MERGE for label in labels)
def wait_for_ci(
client: GiteaClient, sha: str, max_wait: int = MAX_WAIT_SECONDS, poll_interval: int = POLL_INTERVAL_SECONDS
) -> bool:
"""Poll commit statuses until all CI checks are complete (not pending).
Returns True if all checks are successful, False if any failed or timed out.
Uses the combined status endpoint which returns one entry per context
(deduplicated server-side). Filters to "CI /" contexts only, excluding
"Auto-merge / merge" and other non-CI contexts.
"""
elapsed = 0
while elapsed < max_wait:
statuses = client.get_commit_status(sha)
if not statuses:
click.echo(_("No CI checks reported yet, waiting..."))
time.sleep(poll_interval)
elapsed += poll_interval
continue
# Combined endpoint already deduplicates — one entry per context.
# Filter to CI contexts only (excludes "Auto-merge / merge" etc).
ci_statuses = {s.get("context", ""): s for s in statuses if s.get("context", "").startswith("CI /")}
if not ci_statuses:
click.echo(_("No CI checks found yet, waiting..."))
time.sleep(poll_interval)
elapsed += poll_interval
continue
pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")]
if not pending:
# All CI checks are complete — check if they all succeeded.
# "skipped" jobs are considered passing (conditional jobs that didn't run).
failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok", "skipped")]
if failed:
click.echo(_("CI checks failed: {failed}", failed=", ".join(sorted(failed))))
return False
click.echo(_("All CI checks passed."))
return True
click.echo(
_(
"Waiting for CI checks: {pending} ({elapsed}s elapsed)",
pending=", ".join(sorted(pending)),
elapsed=elapsed,
)
)
time.sleep(poll_interval)
elapsed += poll_interval
click.echo(_("Timed out waiting for CI checks after {max_wait}s.", max_wait=max_wait))
return False
@click.command()
@click.argument("branch")
@click.argument("pr_title")
@click.argument("repo")
@click.argument("pr_number")
@click.argument("label_name", required=False, default="")
def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Gitea Actions may not populate github.event.label.name; fall back to API check.
if label_name != READY_TO_MERGE and not has_ready_to_merge_label(client, pr_number):
click.echo(_("Label '{label}' is not '{rtm}', skipping.", label=label_name, rtm=READY_TO_MERGE))
return
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Oops! No task ID (GRM-N) found in branch name '{branch}'.",
branch=branch,
)
)
validate_pr_title(pr_title, task_id)
validate_pr_title_matches_vikunja(pr_title, task_id)
# Enforce APPROVE review before merge (Gap 2 fix)
if not has_approval_review(client, pr_number):
raise click.ClickException(
_(
"Cannot merge: PR #{pr_number} has no APPROVE review. "
"Please review and approve before adding the ready-to-merge label.",
pr_number=pr_number,
)
)
click.echo(_("PR has at least one APPROVE review."))
# Wait for CI checks to complete before attempting merge.
pr = client.get_pr(pr_number)
sha = pr.get("head", {}).get("sha", "")
if sha:
click.echo(_("Waiting for CI checks on commit {sha}...", sha=sha[:8]))
if not wait_for_ci(client, sha):
raise click.ClickException(
_("Cannot merge: CI checks did not pass. Please fix failing checks and re-label.")
)
else:
click.echo(_("Warning: could not determine PR head SHA, proceeding without CI wait."))
# Build merge title: GRM-N <conventional commit message>
commits = client.get_pr_commits(pr_number)
conv_msg = extract_conventional_msg(commits)
if not conv_msg:
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
merge_title = f"{task_id} {conv_msg}"
try:
client.merge_pr(pr_number, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — pull master and rebase, then retry
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
try:
run_cmd(["git", "fetch", "origin", "master"])
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_number, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
"Merge failed after rebase retry: {error}\n"
"Please rebase the PR manually and re-add the ready-to-merge label.",
error=str(retry_err),
)
) from None
else:
raise click.ClickException(
_(
"Merge failed with HTTP {status}: {message}\n"
"Please check the PR is ready and you have merge rights.",
status=e.status,
message=e.message,
)
) from None
click.echo(
_(
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
pr_number=pr_number,
merge_title=merge_title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-297
View File
@@ -1,297 +0,0 @@
#!/usr/bin/env python3
"""Classify git changes as user-facing or workflow-only.
Determines whether changes between two git refs (e.g., last tag and HEAD)
affect the GRM tool itself (user-facing) or only the CI/CD infrastructure
(workflow-only). This is used by:
- **release.py** — skips release when only workflow files changed
- **CI workflow** — skips molecule tests and release dry-run when only
workflow files changed
Classification strategy (safe-by-default):
Any file that is NOT in the explicit workflow-only allowlist is treated
as user-facing. This ensures new file types default to requiring a
release rather than silently skipping it.
Workflow-only paths (infrastructure → no release needed):
- .gitea/workflows/** — Gitea Actions workflows
- scripts/** — All scripts (CI/CD, dev tools, setup)
- src/gitea_runner_manager/__init__.py — Version file (release artifact)
- src/gitea_runner_manager/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
- AGENTS.md — Agent conventions
- REVIEW_CHECKLIST.md — Review checklist
- README.md — README (lean, links to wiki)
- CHANGELOG.md — Changelog (generated)
- TROUBLESHOOTING.md — Troubleshooting guide
- cliff.toml — git-cliff config
- Makefile — Build automation
- .pre-commit-config.yaml — Pre-commit config
- .ansible-lint — Ansible lint config
- .env.example — Environment template
- .gitignore — Git ignore rules
- .ruff.toml — Ruff config (if separate)
- .github/** — GitHub config (if present)
Everything else is user-facing (tool changes → release needed),
including but not limited to:
- src/gitea_runner_manager/*.py — Python CLI source (except __init__.py)
- ansible/** — Ansible role
- pyproject.toml — Package metadata
- Any new file type not in the allowlist
Usage:
python3 scripts/ci/classify_changes.py [--base <ref>] [--head <ref>]
python3 scripts/ci/classify_changes.py --base v0.3.0 --head HEAD
"""
from __future__ import annotations
import subprocess # nosec B404
import sys
import click
from gitea_runner_manager.i18n import _
# Explicit allowlist of workflow-only path patterns.
# Anything NOT matching these is treated as user-facing (safe default).
WORKFLOW_ONLY_PATTERNS = frozenset(
[
# CI/CD infrastructure
".gitea/",
# All scripts are infrastructure (CI/CD, dev tools, setup)
# User-facing code lives in src/gitea_runner_manager/
"scripts/",
# Version file — only contains __version__, not user-facing code.
# Version bumps are a release artifact, not a feature.
"src/gitea_runner_manager/__init__.py",
# Gitea API client — used only by CI/CD scripts, not by the GRM CLI.
"src/gitea_runner_manager/api_clients.py",
# Documentation
"docs/",
"AGENTS.md",
"REVIEW_CHECKLIST.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
# Tests
"tests/",
# Config / build automation
"cliff.toml",
"Makefile",
".pre-commit-config.yaml",
".ansible-lint",
".env.example",
".gitignore",
".ruff.toml",
# Hooks
"hooks/",
# GitHub (if ever added)
".github/",
]
)
def run_git(args: list[str]) -> str:
"""Run a git command and return stdout."""
result = subprocess.run( # nosec B603
args,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip())
)
return result.stdout.strip()
def get_changed_files(base: str, head: str) -> list[str]:
"""Get list of files changed between base and head refs."""
output = run_git(["git", "diff", "--name-only", base, head])
if not output:
return []
return output.split("\n")
def is_workflow_only(file_path: str) -> bool:
"""Check if a file path is workflow-only (infrastructure, not the tool itself).
Uses an explicit allowlist — anything not in the list is treated as
user-facing (safe default that prevents accidental release skips).
"""
return any(file_path.startswith(pattern) or file_path == pattern for pattern in WORKFLOW_ONLY_PATTERNS)
def is_user_facing(file_path: str) -> bool:
"""Check if a file path is user-facing (affects the GRM tool).
Inverse of is_workflow_only — anything not explicitly workflow-only
is treated as user-facing.
"""
return not is_workflow_only(file_path)
def classify_changes(files: list[str]) -> dict[str, list[str]]:
"""Classify changed files into user-facing and workflow-only.
Returns a dict with keys "user_facing" and "workflow_only".
"""
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
def has_user_facing_changes(base: str, head: str) -> bool:
"""Check if any user-facing files changed between base and head.
Imported by ``scripts/ci/release.py`` to decide whether a release
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
"""
files = get_changed_files(base, head)
return any(is_user_facing(f) for f in files)
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def _write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
import os
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option("--base", default=None, help="Base ref (default: latest tag).")
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
@click.option(
"--check",
type=click.Choice(["all", "ansible", "user-facing"]),
default="all",
help="Check specific category: all (default), ansible, or user-facing.",
)
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
if base is None:
base = get_latest_tag()
if not base:
if github_output:
_write_github_output("ansible-changed", "true")
_write_github_output("user-facing-changed", "true")
click.echo("No tags found — treating all changes as user-facing.")
return
if quiet:
click.echo("true")
else:
click.echo(_("No tags found — treating all changes as user-facing."))
return
files = get_changed_files(base, head)
if not files:
if github_output:
_write_github_output("ansible-changed", "false")
_write_github_output("user-facing-changed", "false")
click.echo(f"No changes between {base} and {head}.")
return
if quiet:
click.echo("false")
else:
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
return
if github_output:
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
user_files = [f for f in files if is_user_facing(f)]
_write_github_output("ansible-changed", "true" if ansible_files else "false")
_write_github_output("user-facing-changed", "true" if user_files else "false")
click.echo(f"Ansible files changed: {bool(ansible_files)}")
click.echo(f"User-facing files changed: {bool(user_files)}")
return
if check == "ansible":
# Check only for Ansible-related file changes
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
has_ansible = bool(ansible_files)
if quiet:
click.echo("true" if has_ansible else "false")
return
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
for f in ansible_files:
click.echo(f" {f}")
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
return
if check == "user-facing":
# Check only for user-facing file changes (inverse of workflow-only)
user_files = [f for f in files if is_user_facing(f)]
has_user = bool(user_files)
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
for f in user_files:
click.echo(f" {f}")
click.echo(
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes")
)
return
result = classify_changes(files)
has_user = bool(result["user_facing"])
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
for f in result["user_facing"]:
click.echo(f" {f}")
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
for f in result["workflow_only"]:
click.echo(f" {f}")
if has_user:
status = "USER-FACING changes detected — release needed"
else:
status = "Workflow-only changes — no release needed"
click.echo(_("\nResult: {status}", status=status))
if not has_user:
sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release)
if __name__ == "__main__": # pragma: no cover
main()
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
Usage::
python3 scripts/ci/detect_release_commit.py
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import click
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
def get_commit_message() -> str:
"""Get the subject of the latest git commit."""
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%s"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
return result.stdout.strip()
def is_release_commit(message: str) -> bool:
"""Check if a commit message matches the release commit format."""
return bool(RELEASE_RE.match(message))
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
msg = get_commit_message()
click.echo(f"Commit message: {msg}")
is_release = is_release_commit(msg)
write_github_output("is-release", "true" if is_release else "false")
if is_release:
click.echo("Release commit — skipping all post-merge jobs.")
else:
click.echo("Regular merge commit — running all post-merge jobs.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-180
View File
@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""Discover available Gitea Actions runners for dynamic job distribution.
Queries the Gitea API for registered runners at three levels:
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
2. Organization level: GET /orgs/{org}/actions/runners
3. Instance (admin) level: GET /admin/actions/runners
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
Outputs:
- ``--count``: prints the number of available runners
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
dynamic matrix in Gitea Actions
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
Usage:
python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm
python3 scripts/ci/discover_runners.py --indices
python3 scripts/ci/discover_runners.py --count
"""
from __future__ import annotations
import json
import os
import click
import requests
from gitea_runner_manager.config import GITEA_API_URL
DEFAULT_MAX_RUNNERS = 3
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
"""Query the Gitea API for registered runners at all levels.
Returns the total count of active runners. If the API call fails
(e.g., no admin access for instance-level runners), falls back to
what we can see.
"""
headers = {"Authorization": f"token {token}"}
total = 0
# 1. Repository-level runners
try:
r = requests.get(
f"{api_url}/repos/{owner}/{repo}/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
# 2. Organization-level runners
try:
r = requests.get(
f"{api_url}/orgs/{owner}/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
# 3. Instance-level runners (requires admin scope)
try:
r = requests.get(
f"{api_url}/admin/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
return total
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
"""Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default.
"""
# Try API query if we have a token
if token:
api_count = query_runners(api_url, token, owner, repo)
if api_count > 0:
return api_count
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
env_count = os.environ.get("MOLECULE_RUNNERS")
if env_count:
try:
count = int(env_count)
if count > 0:
return count
except ValueError:
pass
# Fall back to default
return DEFAULT_MAX_RUNNERS
def generate_indices(count: int) -> list[str]:
"""Generate a list of runner indices ["1", "2", ..., "N"].
Uses 1-based string indices because Gitea Actions renders
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
expressions, causing --runner-index to be passed without a value.
The distribute_molecule.py script converts these back to 0-based
internally.
"""
return [str(i + 1) for i in range(count)]
@click.command()
@click.option("--owner", default=None, help="Repository owner (for API query).")
@click.option("--repo", default=None, help="Repository name (for API query).")
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(
owner: str | None,
repo: str | None,
output_count: bool,
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
if owner is None:
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
if repo is None:
repo = os.environ.get("GRM_REPO_NAME", "grm")
count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count)
if github_output:
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
return
if output_count:
click.echo(str(count))
return
if output_indices:
click.echo(json.dumps(indices))
return
# Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}")
click.echo(f"indices={json.dumps(indices)}")
if __name__ == "__main__": # pragma: no cover
main()
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
crosses them with the supported OS platform matrix, then splits the
resulting test pairs evenly across the requested number of runners.
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
so the CI workflow can set the appropriate environment variables.
Usage:
python3 scripts/distribute_molecule.py --runner-index 1 --max-runners 3
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
python3 scripts/distribute_molecule.py --list
# prints all scenarios, one per line
python3 scripts/distribute_molecule.py --list-platforms
# prints all platforms, one per line
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import click
from gitea_runner_manager.i18n import _
from scripts.ci.platforms import PLATFORMS
DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
@dataclass(frozen=True)
class TestPair:
"""A (scenario, platform) combination to test."""
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption."""
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
@staticmethod
def decode(encoded: str) -> TestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return TestPair(
scenario=parts[0],
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
)
def discover_scenarios(root: Path | None = None) -> list[str]:
"""Return sorted list of molecule scenario directory names."""
if root is None:
root = MOLECULE_ROOT
if not root.is_dir():
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
return sorted(scenarios)
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Build the full cross-product of scenarios and platforms."""
if platforms is None:
platforms = PLATFORMS
return [TestPair(s, p) for s in scenarios for p in platforms]
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
"""Return the subset of pairs assigned to *runner_index*."""
groups = distribute(pairs, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_(
"Runner index {index} out of range (0..{max})",
index=runner_index,
max=max_runners - 1,
)
)
return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_ENV file."""
import os
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index (Gitea Actions renders 0 as empty). "
"Converted to zero-based internally. If omitted, prints all groups.",
)
@click.option(
"--max-runners",
type=int,
default=DEFAULT_MAX_RUNNERS,
show_default=True,
help="Total number of parallel runners.",
)
@click.option(
"--list",
"list_all",
is_flag=True,
help="List all discovered scenarios, one per line.",
)
@click.option(
"--list-platforms",
"list_platforms",
is_flag=True,
help="List all supported platforms, one per line.",
)
@click.option(
"--github-env",
"github_env",
is_flag=True,
default=False,
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def cli(
runner_index: int | None,
max_runners: int,
list_all: bool,
list_platforms: bool,
github_env: bool,
skip_if_excess: bool,
) -> None:
scenarios = discover_scenarios()
if list_all:
for s in scenarios:
click.echo(s)
return
if list_platforms:
for p in PLATFORMS:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
pairs = build_pairs(scenarios)
if runner_index is None:
groups = distribute(pairs, max_runners)
for i, group in enumerate(groups):
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("TEST_PAIRS", "")
_write_github_env("SKIP", "true")
return
# Convert 1-based CLI index to 0-based internal index
zero_based = runner_index - 1
assigned = pairs_for_runner(pairs, zero_based, max_runners)
encoded = " ".join(p.encode() for p in assigned)
if github_env:
_write_github_env("TEST_PAIRS", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned pairs: {encoded}")
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""Check documentation coverage for CLI commands and major modules.
Parses Click commands from the CLI source code and checks if each command
has corresponding documentation in the wiki/docs. Reports missing
documentation as warnings and exits with non-zero if coverage is below 100%.
Usage:
python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import click
from gitea_runner_manager.i18n import _
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DOCS_DIR = REPO_ROOT / "docs"
CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py"
# Major modules that should be documented in tech/architecture.md
REQUIRED_MODULES = [
"cli.py",
"runner_manager.py",
"executor.py",
"registry.py",
"i18n.py",
"exceptions.py",
"api_clients.py",
"config.py",
]
# CI scripts that should be documented in tech/ci-cd-workflow.md
REQUIRED_SCRIPTS = [
"auto_merge.py",
"release.py",
"publish.py",
"review_pr.py",
"notify_failure.py",
"post_merge.py",
"classify_changes.py",
"discover_runners.py",
"detect_release_commit.py",
"push_badges.py",
"distribute_molecule.py",
"molecule_ci_guard.py",
"validate_commit_msg.py",
]
def extract_cli_commands() -> list[str]:
"""Extract command names from the CLI source file."""
content = CLI_FILE.read_text()
commands: list[str] = []
# Find all @cli.command(...) occurrences, then the next def statement
for match in re.finditer(r"@cli\.command\b", content):
# Check for explicit name="..." in the decorator arguments
decorator_end = content.find(")", match.start())
decorator_text = content[match.start() : decorator_end + 1]
name_match = re.search(r'name\s*=\s*"([^"]+)"', decorator_text)
if name_match:
commands.append(name_match.group(1))
continue
# Find the next def statement after this decorator
after = content[decorator_end:]
def_match = re.search(r"def\s+(\w+)\s*\(", after)
if def_match:
commands.append(def_match.group(1))
return commands
def check_command_documented(command: str, docs_content: str) -> bool:
"""Check if a CLI command is documented in the docs content."""
# Look for the command name as a heading or in code blocks
patterns = [
rf"##.*\b{re.escape(command)}\b",
rf"`grm\s+{re.escape(command)}\b",
rf"\bgrm\s+{re.escape(command)}\b",
rf"###.*\b{re.escape(command)}\b",
]
return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns)
def check_module_documented(module: str, docs_content: str) -> bool:
"""Check if a module is mentioned in the docs content."""
return module in docs_content
@click.command()
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
@click.option(
"--fail-on-missing",
is_flag=True,
default=False,
help="Exit with non-zero status if any documentation is missing.",
)
def main(docs_dir: str, fail_on_missing: bool) -> None:
docs_path = Path(docs_dir)
cli_commands_file = docs_path / "user" / "cli-commands.md"
architecture_file = docs_path / "tech" / "architecture.md"
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
missing: list[str] = []
total = 0
# Check CLI commands
click.echo(_("Checking CLI command documentation..."))
commands = extract_cli_commands()
total += len(commands)
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
for cmd in commands:
if check_command_documented(cmd, cli_docs):
click.echo(_(" OK: grm {cmd}", cmd=cmd))
else:
click.echo(_(" MISSING: grm {cmd}", cmd=cmd))
missing.append(f"CLI command: grm {cmd}")
# Check modules in architecture.md
click.echo(_("\nChecking module documentation in architecture.md..."))
total += len(REQUIRED_MODULES)
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
for module in REQUIRED_MODULES:
if check_module_documented(module, arch_docs):
click.echo(_(" OK: {module}", module=module))
else:
click.echo(_(" MISSING: {module}", module=module))
missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
total += len(REQUIRED_SCRIPTS)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in REQUIRED_SCRIPTS:
if check_module_documented(script, ci_docs):
click.echo(_(" OK: {script}", script=script))
else:
click.echo(_(" MISSING: {script}", script=script))
missing.append(f"CI script: {script}")
# Report
covered = total - len(missing)
percentage = (covered / total * 100) if total > 0 else 100.0
click.echo(
_(
"\nDoc coverage: {covered}/{total} ({pct}%)",
covered=covered,
total=total,
pct=f"{percentage:.0f}",
)
)
if missing:
click.echo(_("\nMissing documentation:"))
for item in missing:
click.echo(f" - {item}")
if missing and fail_on_missing:
click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."))
sys.exit(1)
if not missing:
click.echo(_("\nAll documentation coverage checks passed!"))
if __name__ == "__main__": # pragma: no cover
main()
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
"""Run molecule tests sequentially while polling Gitea for other runner failures.
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
Pairs are executed one at a time (molecule scenarios share temp directories and
Docker networks, so parallel execution within a single runner is unsafe).
A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner
exits early with code 1.
Usage:
python3 scripts/molecule_ci_guard.py <pair1> <pair2> ...
Environment variables:
GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index).
GITEA_REPOSITORY Repository in "owner/repo" format.
"""
from __future__ import annotations
import contextlib
import os
import signal
import subprocess # nosec B404
import sys
import threading
import time
from pathlib import Path
import click
import requests
from gitea_runner_manager.i18n import _
POLL_INTERVAL = 10
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
"""Return jobs for the given workflow run."""
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
headers = {"Authorization": f"token {token}"}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
return data.get("jobs", [])
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
"""Return True if any other molecule matrix job has failed."""
for job in jobs:
name = job.get("name", "")
if not name.startswith(current_job_name):
continue
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
continue
if job.get("conclusion") == "failure":
return True
return False
def poll_for_other_failures(
gitea_url: str,
owner: str,
repo: str,
token: str,
run_id: int,
job_name: str,
current_index: int,
stop_event: threading.Event,
failed_event: threading.Event,
) -> None:
"""Background thread: poll API and signal if another runner fails."""
while not stop_event.is_set():
try:
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
if any_other_runner_failed(jobs, job_name, current_index):
click.echo(_("Another molecule runner failed. Stopping this runner early."))
failed_event.set()
return
except requests.RequestException as exc:
click.echo(_("API poll warning: {exc}", exc=exc))
stop_event.wait(POLL_INTERVAL)
def build_molecule_cmd(scenario: str) -> list[str]:
"""Build the molecule command for a scenario."""
cmd = ["molecule", "test"]
if scenario != "default":
cmd.extend(["-s", scenario])
return cmd
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair."""
scenario, platform_name, platform_image, platform_command = pair.split("|")
env = base_env.copy()
env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
if platform_command:
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
elif "MOLECULE_PLATFORM_COMMAND" in env:
del env["MOLECULE_PLATFORM_COMMAND"]
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
return env
@click.command()
@click.argument("pairs", nargs=-1, required=True)
def cli(pairs: tuple[str, ...]) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/grm")
owner, sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "grm"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
repo_root = Path(__file__).resolve().parent.parent.parent
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
base_env = os.environ.copy()
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
stop_event = threading.Event()
failed_event = threading.Event()
if gitea_url and token and run_id:
poller = threading.Thread(
target=poll_for_other_failures,
args=(
gitea_url,
owner,
repo,
token,
run_id,
job_name,
current_index,
stop_event,
failed_event,
),
daemon=True,
)
poller.start()
try:
for pair in pairs:
if failed_event.is_set():
sys.exit(1)
scenario = pair.split("|")[0]
platform_name = pair.split("|")[1]
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env)
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(role_dir),
env=env,
preexec_fn=os.setsid,
)
try:
while process.poll() is None:
if failed_event.is_set():
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
sys.exit(1)
rc = process.returncode
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed."))
finally:
stop_event.set()
sys.exit(0)
if __name__ == "__main__": # pragma: no cover
cli()
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""Create a Gitea issue when a CI workflow fails.
Used by the release and publish workflows to alert on failures that would
otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
for issue creation when available, falling back to ``GiteaClient`` (direct
HTTP API) when tea is not installed.
Usage:
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
--commit <commit_sha>
"""
from __future__ import annotations
import contextlib
import os
import shutil
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int | None:
"""Try creating issue via tea CLI. Returns issue index or None on failure."""
if shutil.which("tea") is None:
return None
from scripts.gitea_cli import TeaCLI, TeaCLIError
tea = TeaCLI(repo=repo)
try:
# Check if "bug" label exists
labels: list[str] = []
try:
existing_labels = tea.list_labels(repo)
if any(label.get("name") == "bug" for label in existing_labels):
labels = ["bug"]
except TeaCLIError:
pass
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
if labels:
with contextlib.suppress(TeaCLIError):
tea.add_label(repo, issue["index"], labels)
return int(issue.get("index", 0))
except TeaCLIError:
return None
def _create_issue_via_client(repo: str, title: str, body: str) -> int:
"""Create issue via GiteaClient (direct HTTP API). Returns issue ID."""
token = os.environ.get("REPO_TOKEN", "")
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
label_ids: list[int] = []
for label in client.list_labels():
if label.get("name") == "bug":
label_ids.append(int(label["id"]))
break
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
return int(issue.get("id", 0))
@click.command()
@click.option("--repo", required=True, help="Repository in owner/name format.")
@click.option("--run-id", required=True, help="CI run ID.")
@click.option("--workflow", required=True, help="Workflow name.")
@click.option("--commit", required=True, help="Commit SHA.")
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = (
f"The **{workflow}** workflow failed.\n\n"
f"- **Run ID**: #{run_id}\n"
f"- **Commit**: `{commit[:8]}`\n"
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
f"{repo}/actions/runs/{run_id}\n\n"
f"Please investigate and fix the issue."
)
# Try tea CLI first, fall back to GiteaClient
issue_id = _create_issue_via_tea(repo, title, body)
if issue_id is None:
try:
issue_id = _create_issue_via_client(repo, title, body)
except APIError as e:
raise click.ClickException(
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
) from None
click.echo(
_(
"Created issue #{issue_id}: {title}",
issue_id=issue_id or "?",
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-22
View File
@@ -1,22 +0,0 @@
"""Supported OS platform matrix for molecule tests.
Single source of truth for the platform list used by both:
- ``scripts/ci/distribute_molecule.py`` (CI parallel matrix)
- ``scripts/molecule_all.py`` (local sequential runner)
Keeping this in a dedicated module avoids cross-imports between
dev tools and CI scripts.
"""
from __future__ import annotations
#: Supported OS platform matrix.
#: Each entry maps a short name to (image, command).
#: The command must be systemd since rootless Docker requires
#: loginctl/systemctl --user.
PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
]
-197
View File
@@ -1,197 +0,0 @@
#!/usr/bin/env python3
"""Update Vikunja task after a merge to master.
Usage:
VIKUNJA_TOKEN=<token> python3 scripts/post_merge.py <commit_msg> [--commit-sha <sha>]
"""
import os
import re
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import VikunjaClient
from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
def _get_git_commit_message() -> str:
"""Get the full commit message of the latest commit."""
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%B"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
return result.stdout.strip()
def _get_git_commit_sha() -> str:
"""Get the SHA of the latest commit."""
result = subprocess.run( # nosec B603 B607
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
return result.stdout.strip()
def extract_task_id(commit_msg: str) -> str:
"""Extract GRM-N task identifier from the first line of commit message."""
first_line = commit_msg.split("\n")[0]
match = TASK_ID_RE.search(first_line)
return match.group(0) if match else ""
def extract_conventional_msg(commit_msg: str) -> str:
"""Strip the GRM-N prefix from the commit subject.
Handles both formats:
- ``GRM-N: <message>`` (legacy, colon-separated)
- ``GRM-N <message>`` (current, space-separated)
"""
first_line = commit_msg.split("\n")[0]
return re.sub(r"^GRM-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
"""Resolve GRM-N identifier to Vikunja numeric task ID.
Paginates through the project's tasks to handle projects with more
than 50 tasks.
"""
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return int(matches[0]["id"])
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
raise click.ClickException(
_(
"Could not find Vikunja task for {task_id} in project {project_id}.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
"""Build HTML comment body for Vikunja."""
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
@click.command()
@click.argument("commit_msg", required=False)
@click.option("--commit-sha", default="", help="Commit SHA")
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
@click.option(
"--git-sha",
default="",
help="Read commit message from a specific git SHA (avoids race condition with parallel jobs).",
)
def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) -> None:
if git_sha:
# Read commit message from a specific SHA — this avoids the race
# condition where a parallel job (e.g., release) pushes a new commit
# to master before this job reads HEAD.
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%B", git_sha],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed for SHA {git_sha}: {result.stderr.strip()}")
commit_msg = result.stdout.strip()
if not commit_sha:
commit_sha = git_sha
elif from_git:
commit_msg = _get_git_commit_message()
if not commit_sha:
commit_sha = _get_git_commit_sha()
if not commit_msg:
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
task_id = extract_task_id(commit_msg)
if not task_id:
first_line = commit_msg.split("\n")[0]
# Skip gracefully for infrastructure commits that don't follow
# the GRM-N convention: release commits, reverts, bot commits, etc.
infra_patterns = [
r"^release: v\d+\.\d+\.\d+", # release commits
r"^revert: ", # git revert commits
r"^Merge ", # merge commits
r"^\[skip ci\]", # skip-ci commits
]
for pattern in infra_patterns:
if re.match(pattern, first_line):
click.echo(
_(
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}",
msg=first_line,
)
)
return
# Non-infrastructure commits without GRM-N prefix — warn but don't fail
click.echo(
_(
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.",
msg=first_line,
)
)
return
client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = 0
try:
vikunja_task_id = resolve_task_id(client, task_id)
conv_msg = extract_conventional_msg(commit_msg)
sha = commit_sha or "unknown"
html = build_comment(task_id, conv_msg, sha)
client.post_comment(vikunja_task_id, html)
client.update_task(vikunja_task_id, done=True)
except APIError as e:
# Vikunja is a project management tool — if it's down, the merge
# still succeeded. Warn but don't fail the post-merge workflow.
click.echo(
_(
"Warning: Vikunja API error (HTTP {status}): {message}. "
"Task {task_id} was NOT updated. The merge succeeded — "
"please update the Vikunja task manually.",
status=e.status,
message=e.message,
task_id=task_id,
)
)
return
click.echo(
_(
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
task_id=task_id,
vikunja_id=vikunja_task_id,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-467
View File
@@ -1,467 +0,0 @@
#!/usr/bin/env python3
"""Automated PR review: check architecture compliance, best practices, and quality.
Fetches the PR diff via the Gitea API, runs a series of automated checks,
and posts a structured review using GiteaClient.create_review.
Checks performed:
1. Architecture compliance — no business logic in CLI, no direct subprocess
calls outside executor, no hardcoded config that should be in config.py
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
left in merged code, no functions > 50 lines
3. Security — no secrets in code, no shell=True, no eval/exec
4. Documentation — new CLI commands documented, new modules in architecture.md
5. Test coverage — 100% enforced by pytest-cov (checked in quality job)
6. Commit conventions — conventional commit format on branch commits
Usage:
REPO_TOKEN=<token> python3 scripts/ci/pr_review.py <pr_number> <owner/repo>
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
# Files that are exempt from certain checks
WORKFLOW_ONLY_SUFFIXES = (".yml", ".yaml", ".md", ".json", ".toml", ".cfg", ".ini", ".txt")
PYTHON_SUFFIX = ".py"
# Architecture rules
CLI_FILE = "src/gitea_runner_manager/cli.py"
EXECUTOR_FILE = "src/gitea_runner_manager/executor.py"
CONFIG_FILE = "src/gitea_runner_manager/config.py"
# Patterns that indicate business logic in CLI (should be in runner_manager.py)
BUSINESS_LOGIC_IN_CLI = [
(r"subprocess\.(run|call|Popen|check_output|check_call)", "subprocess call in CLI — delegate to executor.py"),
(r"\bos\.system\b", "os.system call in CLI — delegate to executor.py"),
(r"\bansible-playbook\b", "ansible-playbook reference in CLI — delegate to executor.py"),
]
# Patterns that indicate bad practices
BAD_PRACTICES = [
(r"\bprint\s*\(", "print() found — use click.echo() for user output"),
(r"\beval\s*\(", "eval() found — security risk, avoid dynamic code execution"),
(r"\bexec\s*\(", "exec() found — security risk, avoid dynamic code execution"),
(r"shell\s*=\s*True", "shell=True found — security risk, use shell=False with list args"),
(r"except\s*:", "bare except found — catch specific exceptions"),
(r"except\s+Exception\s*:", "broad Exception catch — catch specific exceptions"),
(r"#\s*(TODO|FIXME|HACK|XXX)", "TODO/FIXME found — resolve before merging"),
]
# Patterns for hardcoded config values that should be in config.py
HARDCODED_CONFIG = [
(r"https?://[a-z]+\.[a-z]+\.[a-z]+", "hardcoded URL — move to config.py with env var override"),
]
@dataclass
class ReviewResult:
"""Result of automated review checks."""
issues: list[dict[str, Any]] = field(default_factory=list)
summary: list[str] = field(default_factory=list)
@property
def has_issues(self) -> bool:
return bool(self.issues)
def add_issue(self, file_path: str, line: int, message: str, severity: str = "warning") -> None:
self.issues.append(
{
"path": file_path,
"body": f"[{severity}] {message}",
"new_position": line,
}
)
def add_summary(self, text: str) -> None:
self.summary.append(text)
def is_python_file(path: str) -> bool:
"""Check if a file is a Python source file."""
return path.endswith(PYTHON_SUFFIX) and not path.startswith("tests/")
def is_workflow_only(path: str) -> bool:
"""Check if a file is workflow/config/docs only (not Python source)."""
return path.endswith(WORKFLOW_ONLY_SUFFIXES) or path.startswith((".gitea/", "docs/", "ansible/"))
def check_architecture_compliance(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that changes follow the documented architecture."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Check for business logic in CLI
if path == CLI_FILE:
for pattern, msg in BUSINESS_LOGIC_IN_CLI:
if re.search(pattern, content):
result.add_issue(path, current_line, msg, "error")
if not result.issues:
result.add_summary("- Architecture compliance: OK")
def check_best_practices(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for common code quality issues."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
for pattern, msg in BAD_PRACTICES:
if re.search(pattern, content):
result.add_issue(path, current_line, msg, "warning")
if not any(i["body"].startswith("[warning]") for i in result.issues):
result.add_summary("- Best practices: OK")
def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for security issues in changed files."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Check for hardcoded secrets
secret_re = r'(token|password|secret|key)\s*=\s*["\'][^"\']{8,}["\']' # nosec B105
is_secret = re.search(secret_re, content, re.IGNORECASE)
is_comment = content.strip().startswith("#")
is_example = "your-" in content or "example" in content
if is_secret and not is_comment and not is_example:
result.add_issue(
path,
current_line,
"potential hardcoded secret — use environment variable",
"error",
)
if not any(i["body"].startswith("[error]") and "secret" in i["body"] for i in result.issues):
result.add_summary("- Security: OK")
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that no new function is excessively long (> 50 lines)."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
# Count consecutive added lines within a function
lines = patch.split("\n")
current_line = 0
func_start = 0
func_name = ""
added_in_func = 0
for line in lines:
if line.startswith("@@"):
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
func_name = ""
added_in_func = 0
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
func_match = re.match(r"\s*def\s+(\w+)\s*\(", content)
if func_match:
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
func_name = func_match.group(1)
func_start = current_line
added_in_func = 0
else:
added_in_func += 1
elif line.startswith(" ") or line.startswith("-"):
pass # context or removed line
# Check last function
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that documentation is updated for relevant changes."""
has_src_changes = any(
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
)
has_doc_changes = any(
f.get("filename", "").startswith("docs/") or f.get("filename", "") in ("README.md", "AGENTS.md", "CHANGELOG.md")
for f in files
)
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
if has_src_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
elif has_ansible_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
else:
result.add_summary("- Documentation: OK")
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that tests are updated for source changes."""
has_src_changes = any(
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
)
has_test_changes = any(f.get("filename", "").startswith("tests/") for f in files)
if has_src_changes and not has_test_changes:
result.add_summary("- Tests: WARNING — source files changed but no test files updated")
else:
result.add_summary("- Tests: OK")
def check_commit_conventions(client: GiteaClient, pr_number: str, result: ReviewResult) -> None:
"""Check that PR commits follow conventional commit format.
Verifies that at least one commit on the PR branch matches the
conventional commit pattern (type: description). Merge commits
and revert commits are exempt.
"""
try:
commits = client.get_pr_commits(pr_number)
except APIError as e:
result.add_summary(f"- Commit conventions: ERROR — could not fetch commits: {e.message}")
return
if not commits:
result.add_summary("- Commit conventions: OK (no commits to check)")
return
from gitea_runner_manager.config import CONVENTIONAL_RE
has_conventional = False
non_conventional: list[str] = []
for commit in commits:
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
# Skip merge commits and revert commits
if message.startswith(("Merge", "Revert")):
continue
if CONVENTIONAL_RE.match(message):
has_conventional = True
else:
non_conventional.append(message[:60])
if has_conventional:
result.add_summary("- Commit conventions: OK")
elif non_conventional:
result.add_summary(
f"- Commit conventions: WARNING — no conventional commit found. "
f"Non-conventional commits: {', '.join(non_conventional[:3])}"
)
else:
result.add_summary("- Commit conventions: OK (all commits are merges/reverts)")
def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
"""Run all review checks and return the result."""
result = ReviewResult()
try:
files = client.get_pr_files(pr_number)
except APIError as e:
result.add_summary(f"- ERROR: Could not fetch PR files: {e.message}")
return result
if not files:
result.add_summary("- No files changed in this PR")
return result
# Run all checks
check_architecture_compliance(files, result)
check_best_practices(files, result)
check_security(files, result)
check_function_length(files, result)
check_documentation(files, result)
check_test_coverage(files, result)
check_commit_conventions(client, pr_number, result)
return result
def build_review_body(result: ReviewResult) -> str:
"""Build the review body text from the review result."""
lines = ["## Automated PR Review", ""]
for item in result.summary:
lines.append(item)
if result.issues:
lines.append("")
lines.append(f"**{len(result.issues)} issue(s) found:**")
lines.append("")
for issue in result.issues:
lines.append(f"- `{issue['path']}:{issue['new_position']}` — {issue['body']}")
else:
lines.append("")
lines.append("No issues found by automated checks.")
lines.append("")
lines.append("---")
lines.append("**Manual review required:** Before approving, review every category in")
lines.append("[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) and confirm with:")
lines.append("```bash")
lines.append("python3 scripts/ci/review_pr.py <PR> <owner/repo> \\")
lines.append(" --event APPROVE --checklist-confirmed \\")
lines.append(' --body "<substantive review summary>"')
lines.append("```")
return "\n".join(lines)
def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> dict[str, Any]:
"""Post the review to the PR.
Uses REQUEST_CHANGES when issues are found, COMMENT otherwise.
Never uses APPROVE — the bot shares the PR author's token, so
Gitea rejects self-approval. The actual APPROVE must come from
the manual review step.
"""
body = build_review_body(result)
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
comments = result.issues if result.has_issues else []
return client.create_review(pr_number, event=event, body=body, comments=comments)
@click.command()
@click.argument("pr_number")
@click.argument("repo")
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
def main(pr_number: str, repo: str, dry_run: bool) -> None:
"""Run automated PR review and post results to Gitea."""
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
result = run_review(client, pr_number)
body = build_review_body(result)
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
click.echo(f"Review event: {event}")
click.echo(f"Issues found: {len(result.issues)}")
click.echo("")
click.echo(body)
if dry_run:
click.echo("\n[dry-run] Review not posted.")
return
try:
review = post_review(client, pr_number, result)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
click.echo(
_(
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
review_id=review_id,
pr_number=pr_number,
event=event,
num_comments=len(result.issues),
)
)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""Build package, optionally publish to PyPI, and create Gitea release.
Uses git-cliff to generate the release notes from conventional commits.
Uses the ``tea`` Gitea CLI for release creation.
Usage:
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
"""
import os
import shutil
import subprocess # nosec B404
import sys
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.i18n import _
from scripts.gitea_cli import TeaCLI, TeaCLIError
load_dotenv(override=True)
CLIFF_CONFIG = "cliff.toml"
def generate_release_notes(tag: str) -> str:
"""Generate release notes for the given tag using git-cliff.
Falls back to a generic message if git-cliff is not available.
"""
cliff_bin = shutil.which("git-cliff")
if not cliff_bin:
return f"Release {tag}\n\nSee CHANGELOG.md for details."
try:
result = subprocess.run( # nosec B603
[cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except FileNotFoundError:
pass
return f"Release {tag}\n\nSee CHANGELOG.md for details."
def build_package() -> None:
"""Build the Python package using python -m build."""
result = subprocess.run( # nosec B603
[sys.executable, "-m", "build"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Package build failed:\n{stderr}",
stderr=result.stderr.strip(),
)
)
def publish_to_pypi(token: str) -> None:
"""Publish built packages to PyPI using twine."""
result = subprocess.run( # nosec B603
[
sys.executable,
"-m",
"twine",
"upload",
"dist/*",
"-u",
"__token__",
"-p",
token,
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! PyPI publish failed:\n{stderr}",
stderr=result.stderr.strip(),
)
)
click.echo(_("Published to PyPI."))
@click.command()
@click.argument("tag")
@click.argument("repo")
def main(tag: str, repo: str) -> None:
gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
pypi_token = os.environ.get("PYPI_TOKEN", "")
build_package()
if pypi_token:
publish_to_pypi(pypi_token)
else:
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
tea = TeaCLI(repo=repo)
release_body = generate_release_notes(tag)
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
click.echo(
_(
"Nice! Gitea release {tag} created.",
tag=tag,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Generate badge SVG files and push them to the ``badges`` branch.
Replaces the inline shell script in the post-merge workflow with a
tested Python equivalent.
The script fetches the latest master before generating badges so that
the version badge always reflects the current state of the repository
(even if a release commit was pushed moments before by the parallel
release job).
Usage::
python3 scripts/ci/push_badges.py
"""
from __future__ import annotations
import subprocess # nosec B404
import sys
from pathlib import Path
from typing import Any
import click
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Run a command and return the result."""
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
def fetch_latest_master(branch: str = "master") -> None:
"""Fetch and hard-reset to the latest remote branch.
Ensures the working tree reflects the absolute latest state of the
remote, which is critical when the release job may have just pushed
a new version commit.
"""
_run(["git", "fetch", "origin", branch]) # nosec B607
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
click.echo(f"Synced to latest origin/{branch}")
def generate_badges(output_dir: str) -> None:
"""Generate badge SVG files using generate_badges.py."""
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg"))
if not badges:
raise click.ClickException("No badge SVG files generated")
click.echo(f"Generated {len(badges)} badge files")
def push_to_badges_branch(badges_dir: str) -> None:
"""Push generated badges to the orphan ``badges`` branch."""
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
_run(["git", "rm", "-rf", "."]) # nosec B607
# Copy badge files to root
import shutil
for svg in Path(badges_dir).glob("*.svg"):
shutil.copy2(svg, Path.cwd() / svg.name)
_run(["git", "add", "./*.svg"]) # nosec B607
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
click.echo("Badges pushed to badges branch")
@click.command()
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
@click.option("--branch", default="master", help="Branch to sync before generating badges.")
def main(output_dir: str, branch: str) -> None:
"""Generate badges and push them to the badges branch."""
fetch_latest_master(branch)
generate_badges(output_dir)
push_to_badges_branch(output_dir)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-369
View File
@@ -1,369 +0,0 @@
#!/usr/bin/env python3
"""Automated release: calculate next version, update files, tag, and push.
Uses git-cliff to determine the next semver version from conventional commits
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
source of truth, read by setuptools via ``dynamic = ["version"]``) and
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
with the changelog as the tag message, and pushes both to trigger the publish
workflow.
**Test enforcement**: Before committing or tagging, the script runs
``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy.
If either fails, the release is aborted — no commit, no tag. This ensures
we never release a version that fails tests. Use ``--skip-tests`` only for
emergency releases (not recommended).
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
clean while still being descriptive. Loops are prevented by the
``has_unreleased_changes`` check — after a release commit is tagged, the next
run finds no unreleased changes and exits.
This script is idempotent: if there are no new conventional commits since the
last tag, it exits with a message and does nothing. If the tag already exists
(e.g., from a partial previous run), it skips tag creation and only pushes.
Usage:
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run] [--skip-tests]
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.i18n import _
from scripts.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
load_dotenv(override=True)
INIT_FILE = "src/gitea_runner_manager/__init__.py"
CHANGELOG_FILE = "CHANGELOG.md"
CLIFF_CONFIG = "cliff.toml"
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
if result.returncode != 0:
return ""
return result.stdout.strip()
def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False)
return bool(result.stdout.strip())
def get_bumped_version() -> str:
"""Use git-cliff to calculate the next version from conventional commits."""
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
version = result.stdout.strip()
if not version:
raise click.ClickException(_("git-cliff returned empty version."))
# git-cliff may return with or without 'v' prefix
return version.lstrip("v")
def get_changelog(new_version: str) -> str:
"""Generate changelog content for the new version using git-cliff."""
result = run_cmd(
[
"git-cliff",
"--config",
CLIFF_CONFIG,
"--tag",
f"v{new_version}",
"--unreleased",
"--bump",
]
)
return result.stdout.strip()
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
"""Check if there are unreleased conventional commits since the last tag.
Uses ``git log`` to check for commits between the last tag and HEAD.
This is more reliable than comparing version strings — if git-cliff
bumps to the same version (e.g., two fix commits between tags), the
version comparison would incorrectly report "no unreleased changes"
even though there are commits that haven't been released yet.
"""
latest = get_latest_tag()
if not latest:
return True
# Check for any commits since the last tag
result = run_cmd(
["git", "log", f"{latest}..HEAD", "--oneline"],
check=False,
)
if result.returncode != 0:
return False
return bool(result.stdout.strip())
def update_init_version(new_version: str) -> None:
"""Update __version__ in __init__.py."""
with open(INIT_FILE) as f:
content = f.read()
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
updated = re.sub(
r'^__version__\s*=\s*"[^"]*"',
f'__version__ = "{new_version}"',
content,
count=1,
flags=re.MULTILINE,
)
with open(INIT_FILE, "w") as f:
f.write(updated)
def update_changelog(changelog: str) -> None:
"""Prepend the new changelog section to CHANGELOG.md.
The changelog from git-cliff may include a header (e.g., "# Changelog").
This function strips everything before the first ``## [`` version section
before inserting, to avoid duplicating the header.
"""
# Strip git-cliff header — keep only from the first version section
section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE)
if section_match:
changelog = changelog[section_match.start() :]
try:
with open(CHANGELOG_FILE) as f:
existing = f.read()
except FileNotFoundError:
with open(CHANGELOG_FILE, "w") as f:
f.write(changelog + "\n")
return
# Find the first version section header (## [...] or ## [unreleased])
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
if match:
# Insert before the first version section
pos = match.start()
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
else:
# No version sections found — append
updated = existing.rstrip() + "\n\n" + changelog + "\n"
with open(CHANGELOG_FILE, "w") as f:
f.write(updated)
def commit_release_changes(new_version: str) -> bool:
"""Stage version file and changelog, then create a release commit.
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
The commit is created with ``--no-verify`` to bypass the commit-msg hook
(which requires ``GRM-N:`` prefix for master commits) since release
commits are a special case generated by the release script.
Returns True if a commit was created, False if there were no staged changes.
"""
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
return True
def run_tests() -> None:
"""Run lint and tests to verify the release is healthy.
This is called *after* version files are updated but *before* the tag is
created, ensuring we never tag a release that fails tests.
"""
click.echo(_("Running lint checks..."))
lint = run_cmd(["make", "lint-ruff"], check=False)
if lint.returncode != 0:
raise click.ClickException(
_(
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(),
)
)
click.echo(_("Lint passed."))
click.echo(_("Running tests..."))
tests = run_cmd(["make", "pytest-cov"], check=False)
if tests.returncode != 0:
raise click.ClickException(
_(
"Tests failed — refusing to release. Fix test failures first.\n{stderr}",
stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(),
)
)
click.echo(_("Tests passed."))
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
"""Create an annotated tag with the changelog as message and push it.
Returns True if the tag was created/pushed, False if it already existed.
"""
tag = f"v{new_version}"
if tag_exists(tag):
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False)
return False
tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run:
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", tag])
return True
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option(
"--skip-tests",
is_flag=True,
default=False,
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
)
def main(dry_run: bool, skip_tests: bool) -> None:
# Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
if branch != "master" and not dry_run:
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
if branch != "master" and dry_run:
click.echo(
_(
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
branch=branch,
)
)
# Release lock: if HEAD is already a release commit, another release
# run is in progress (or already completed). Skip to prevent duplicate tags.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
click.echo(
_(
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
msg=head_msg,
)
)
return
# Check if any user-facing files changed since the last tag.
# If only workflow/infra files changed, skip the release entirely.
latest_tag = get_latest_tag()
if latest_tag and not has_user_facing_changes(latest_tag, "HEAD"):
click.echo(
_(
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
tag=latest_tag,
)
)
return
# Calculate next version (single git-cliff call — Gap 7 fix)
new_version = get_bumped_version()
# Check for unreleased changes (reuses the version we just calculated)
if not has_unreleased_changes(bumped_version=new_version):
click.echo(_("No unreleased changes found. Nothing to release."))
return
current_tag = get_latest_tag()
click.echo(
_(
"Bumping version: {current} -> v{new_version}",
current=current_tag or "(none)",
new_version=new_version,
)
)
# Generate changelog
changelog = get_changelog(new_version)
if not changelog:
click.echo(_("Warning: git-cliff generated empty changelog."))
if dry_run:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
click.echo(_("[dry-run] Would push commit to master"))
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
return
# Update version file
update_init_version(new_version)
click.echo(_("Updated version in {init}", init=INIT_FILE))
# Update CHANGELOG.md (Gap 3 fix)
update_changelog(changelog)
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
# Verify tests pass BEFORE committing or tagging.
# This ensures we never release a version that fails tests.
if skip_tests:
click.echo(_("WARNING: --skip-tests passed — skipping test verification."))
else:
run_tests()
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
committed = commit_release_changes(new_version)
if committed:
click.echo(_("Created release commit."))
# Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
run_cmd(["git", "push", "origin", "master"])
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — no staged changes."))
# Create and push tag (Gap 4: handles existing tag)
created = create_and_push_tag(new_version, changelog, dry_run)
if created:
click.echo(
_(
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
version=new_version,
)
)
else:
click.echo(
_(
"Tag v{version} already existed. Publish workflow should already have been triggered.",
version=new_version,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-163
View File
@@ -1,163 +0,0 @@
#!/usr/bin/env python3
"""Post a review on a Gitea pull request.
Used by the GRM workflow to post structured PR reviews. The review body
is provided via --body and inline comments via a JSON file
(--comments-json) or stdin (--comments-stdin).
.. note::
The ``tea`` CLI v0.14.1 only supports interactive reviews (no
``--approve``/``--comment`` flags), so this script uses
``GiteaClient`` (direct HTTP API) for posting reviews. When a newer
version of tea adds non-interactive review support, this can be
switched to use ``TeaCLI.review_pr()``.
Usage:
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <repo> \
--event COMMENT \
--body "Review body text" \
--comments-json comments.json
The comments JSON file is a list of objects with keys:
- path: file path in the repo
- body: comment text
- new_position: line number in the new file (1-based)
- old_position: (optional) line number in the old file
For APPROVE events, --checklist-confirmed is required. This attests
that the reviewer has gone through every category in
REVIEW_CHECKLIST.md. The review body must also be substantive
(> 20 characters) — trivial "LGTM" approvals are rejected by the
auto-merge gate.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
VALID_EVENTS = ("APPROVE", "REQUEST_CHANGES", "COMMENT")
def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict[str, Any]]:
"""Parse inline comments from a JSON file or stdin."""
if comments_json:
try:
with open(comments_json) as f:
data = json.load(f)
except json.JSONDecodeError as e:
raise click.ClickException(_("Invalid JSON in comments file: {error}", error=str(e))) from None
if not isinstance(data, list):
raise click.ClickException(_("Comments JSON must be a list of objects."))
return data
if comments_stdin:
raw = sys.stdin.read().strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise click.ClickException(_("Invalid JSON on stdin: {error}", error=str(e))) from None
if not isinstance(data, list):
raise click.ClickException(_("Stdin comments JSON must be a list of objects."))
return data
return []
@click.command()
@click.argument("pr_number")
@click.argument("repo")
@click.option(
"--event",
default="COMMENT",
type=click.Choice(VALID_EVENTS),
help="Review event type: APPROVE, REQUEST_CHANGES, or COMMENT.",
)
@click.option("--body", default="", help="Top-level review body text.")
@click.option("--comments-json", default=None, help="Path to JSON file with inline comments.")
@click.option(
"--comments-stdin",
is_flag=True,
default=False,
help="Read inline comments JSON from stdin.",
)
@click.option(
"--checklist-confirmed",
is_flag=True,
default=False,
help="Required for APPROVE: confirms all REVIEW_CHECKLIST.md categories reviewed.",
)
def main(
pr_number: str,
repo: str,
event: str,
body: str,
comments_json: str | None,
comments_stdin: bool,
checklist_confirmed: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
comments = parse_comments(comments_json, comments_stdin)
if event != "APPROVE" and not body and not comments:
raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event))
if event == "APPROVE":
if not checklist_confirmed:
raise click.ClickException(
_(
"APPROVE requires --checklist-confirmed. "
"Review every category in REVIEW_CHECKLIST.md before approving."
)
)
if len(body.strip()) <= 20 and not comments:
raise click.ClickException(
_(
"APPROVE review body must be substantive (> 20 characters) "
"or include inline comments. Trivial approvals are rejected."
)
)
try:
review = client.create_review(pr_number, event=event, body=body, comments=comments)
except APIError as e:
raise click.ClickException(
_(
"Failed to post review: HTTP {status} — {message}",
status=e.status,
message=e.message,
)
) from None
review_id = review.get("id", "?")
click.echo(
_(
"Review #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
review_id=review_id,
pr_number=pr_number,
event=event,
num_comments=len(comments),
)
)
if __name__ == "__main__": # pragma: no cover
main()
-311
View File
@@ -1,311 +0,0 @@
#!/usr/bin/env python3
"""Sync documentation from /docs/ to the Gitea wiki via API.
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to
map file paths to wiki page titles, and creates/updates wiki pages via the
Gitea API. Pages that exist in the wiki but not in the mapping are left
untouched (not deleted).
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} → {title, content_base64, ...}
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
Usage:
REPO_TOKEN=<token> python3 scripts/ci/sync_wiki.py [--dry-run] [--repo owner/repo]
"""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs"
MAPPING_FILE = DOCS_DIR / "mapping.json"
def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json."""
with open(MAPPING_FILE) as f:
return json.load(f)
def read_doc_content(file_path: str) -> str:
"""Read markdown content from a docs file."""
full_path = DOCS_DIR / file_path
with open(full_path) as f:
return f.read()
def encode_content(content: str) -> str:
"""Encode content as base64 for the Gitea wiki API.
The Gitea wiki API requires content_base64, not plain content.
Sending plain content silently fails (pages are created/updated
but with empty content).
"""
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def decode_content(content_b64: str) -> str:
"""Decode base64 content from the Gitea wiki API."""
if not content_b64:
return ""
return base64.b64decode(content_b64).decode("utf-8")
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: sub_url}."""
try:
pages = client._request("GET", "/wiki/pages").json()
except APIError:
return {}
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
"""Fetch a wiki page's content by sub_url, decoded from base64."""
try:
page = client._request("GET", f"/wiki/page/{sub_url}").json()
return decode_content(page.get("content_base64", ""))
except APIError:
return ""
def sync_page(
client: GiteaClient,
page_title: str,
content: str,
existing_pages: dict[str, str],
dry_run: bool,
) -> str:
"""Create or update a single wiki page.
Returns "created", "updated", or "skipped" (if dry-run).
"""
if dry_run:
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
return "skipped"
content_b64 = encode_content(content)
if page_title in existing_pages:
# Update existing page via PATCH
sub_url = existing_pages[page_title]
client._request(
"PATCH",
f"/wiki/page/{sub_url}",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — update {page_title}",
},
)
return "updated"
# Create new page via POST /wiki/new
client._request(
"POST",
"/wiki/new",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — create {page_title}",
},
)
return "created"
def verify_wiki_page(
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
) -> bool:
"""Verify that a wiki page has non-empty content matching the docs.
Returns True if the page content matches, False otherwise.
"""
if page_title not in existing_pages:
return False
sub_url = existing_pages[page_title]
actual = fetch_page_content(client, sub_url)
return actual.strip() == expected_content.strip()
def verify_wiki_integrity(
client: GiteaClient,
mapping: dict[str, str],
synced: dict[str, str],
) -> list[str]:
"""Comprehensive wiki verification.
Checks:
1. Every mapped page exists in the wiki
2. Every mapped page has non-empty content
3. Every mapped page's content matches the docs
4. No stale pages exist in the wiki (pages not in mapping)
5. Page count matches
Returns a list of failure messages (empty if all checks pass).
"""
failures: list[str] = []
existing_pages = list_wiki_pages(client)
expected_titles = set(mapping.values())
# Check 1: Page count
if len(existing_pages) != len(expected_titles):
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
# Check 2: Missing pages (in mapping but not in wiki)
missing = expected_titles - set(existing_pages.keys())
for title in sorted(missing):
failures.append(f"Missing page: {title}")
# Check 3: Stale pages (in wiki but not in mapping)
stale = set(existing_pages.keys()) - expected_titles
for title in sorted(stale):
failures.append(f"Stale page (not in mapping): {title}")
# Check 4: Content verification
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
if not ok:
sub_url = existing_pages.get(page_title, "?")
actual = fetch_page_content(client, sub_url)
if not actual.strip():
failures.append(f"Empty content: {page_title}")
else:
failures.append(f"Content mismatch: {page_title}")
return failures
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
@click.option(
"--verify",
is_flag=True,
default=False,
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
)
@click.option(
"--strict",
is_flag=True,
default=False,
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
)
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if repo is None:
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
repo_name = os.environ.get("GRM_REPO_NAME", "grm")
else:
owner, repo_name = repo.split("/")
if not MAPPING_FILE.exists():
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
mapping = load_mapping()
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
existing_pages = list_wiki_pages(client)
if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
created = 0
updated = 0
skipped = 0
synced: dict[str, str] = {} # title -> content, for verification
for file_path, page_title in sorted(mapping.items()):
try:
content = read_doc_content(file_path)
except FileNotFoundError:
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
skipped += 1
continue
if not content.strip():
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
skipped += 1
continue
result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created":
created += 1
click.echo(_(" Created: {title}", title=page_title))
elif result == "updated":
updated += 1
click.echo(_(" Updated: {title}", title=page_title))
else:
skipped += 1
synced[page_title] = content
click.echo(
_(
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
created=created,
updated=updated,
skipped=skipped,
)
)
# --strict implies --verify
do_verify = verify or strict
if do_verify and not dry_run:
if strict:
click.echo(_("\nRunning full wiki integrity check..."))
failures = verify_wiki_integrity(client, mapping, synced)
if failures:
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
for f in failures:
click.echo(f" - {f}")
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
else:
click.echo(_("\nVerifying wiki pages have content..."))
# Re-fetch the page list to get updated sub_urls
existing_pages = list_wiki_pages(client)
failures = 0
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
if ok:
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
else:
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
failures += 1
if failures > 0:
click.echo(
_(
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
failures=failures,
)
)
raise click.ClickException(
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
)
click.echo(_("\nVerification passed — all wiki pages have correct content."))
if __name__ == "__main__": # pragma: no cover
main()
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""Validate commit messages for GRM.
Rules:
- On feature branches: conventional commits ONLY, must NOT include GRM-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'GRM-24: fix: resolve timeout'.
"""
import re
import subprocess # nosec B404
import click
from gitea_runner_manager.config import CONVENTIONAL_RE
from gitea_runner_manager.i18n import _
MASTER_TASK_ID_RE = re.compile(r"^GRM-\d+:")
def first_line(text: str) -> str:
return text.split("\n")[0]
def get_branch() -> str:
try:
result = subprocess.run( # nosec
["git", "symbolic-ref", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return ""
@click.command()
@click.argument("commit_msg_file")
def main(commit_msg_file: str) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
branch = get_branch()
subject = first_line(msg)
if branch == "master":
if not MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Master branch commits must start with a task ID.\n"
" Expected: GRM-N: <conventional commit message>\n"
" Got: {subject}",
subject=subject,
)
)
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
if not CONVENTIONAL_RE.match(remainder):
raise click.ClickException(
_(
"Oops! Master branch commit must follow conventional format after task ID.\n"
" Expected: GRM-N: <type>: <description>\n"
" Got: {subject}",
subject=subject,
)
)
return
if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI."
)
)
if not CONVENTIONAL_RE.match(subject):
raise click.ClickException(
_(
"Oops! Commit message must follow conventional commit format.\n"
" Expected: <type>: <description>\n"
" Got: {subject}\n"
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
" perf, test, ci, build, revert, BREAKING CHANGE",
subject=subject,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-129
View File
@@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""Configure GRM repository: branch protection + labels via Gitea REST API.
Uses the ``tea`` Gitea CLI for label creation and the ``GiteaClient`` for
branch protection and repo settings (tea only supports basic protect/unprotect,
not the detailed config we need with status checks and required approvals).
Usage:
REPO_TOKEN=<token> python3 scripts/configure_repo.py
"""
import http
import os
from typing import cast
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
GITEA_API_URL,
LABEL_CONFIG,
REPO_NAME,
REPO_OWNER,
REPO_SETTINGS_CONFIG,
)
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
from scripts.gitea_cli import TeaCLI, TeaCLIError
load_dotenv(override=True)
def _handle_http_error(e: APIError) -> None:
"""Raise a user-friendly Click exception for HTTP errors."""
if e.status == http.HTTPStatus.FORBIDDEN:
raise click.ClickException(
_(
"HTTP {status} Forbidden — your token lacks admin rights.\n"
"Make sure the token belongs to a repo owner or organisation admin.\n"
"Alternatively, configure branch protection manually in Settings → Branches.",
status=e.status,
)
)
raise click.ClickException(_("HTTP error: {status} — {message}", status=e.status, message=e.message))
def _ensure_label_via_tea(tea: TeaCLI, repo: str, name: str, color: str, description: str) -> bool:
"""Create a label via tea if it doesn't already exist.
Returns True if created, False if it already existed.
"""
try:
existing = tea.list_labels(repo)
if any(label.get("name") == name for label in existing):
return False
tea.create_label(repo, name=name, color=color, description=description)
return True
except TeaCLIError:
# Fall back to GiteaClient if tea fails
return _ensure_label_via_client(name, color, description)
def _ensure_label_via_client(name: str, color: str, description: str) -> bool:
"""Fallback: create label via GiteaClient. Returns True if created."""
client = GiteaClient(
GITEA_API_URL,
os.environ.get("REPO_TOKEN", ""),
REPO_OWNER,
REPO_NAME,
)
result = client.ensure_label(name=name, color=color, description=description)
return result is not None
def main() -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
repo = f"{REPO_OWNER}/{REPO_NAME}"
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
tea = TeaCLI(repo=repo)
try:
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
click.echo(_(" - Direct pushes: BLOCKED (require PR)"))
click.echo(
_(
" - Required approvals: {count}",
count=BRANCH_PROTECTION_CONFIG["required_approvals"],
)
)
click.echo(_(" - Dismiss stale approvals: yes"))
click.echo(_(" - Block outdated branches: yes"))
click.echo(_(" - Block rejected reviews: yes"))
checks = ", ".join(cast(list[str], BRANCH_PROTECTION_CONFIG["status_check_contexts"]))
click.echo(_(" - Required status checks: {checks}", checks=checks))
click.echo("")
label_name = cast(str, LABEL_CONFIG["name"])
click.echo(_("Creating {label} label...", label=label_name))
created = _ensure_label_via_tea(
tea,
repo,
name=label_name,
color=cast(str, LABEL_CONFIG["color"]),
description=cast(str, LABEL_CONFIG["description"]),
)
if created:
click.echo(_(" Label '{label}' created.", label=label_name))
else:
click.echo(_(" Label '{label}' already exists.", label=label_name))
click.echo("")
click.echo(_("Configuring repository settings..."))
client.update_repo_settings(cast(dict[str, object], REPO_SETTINGS_CONFIG))
click.echo(_(" - Auto-delete branch after merge: yes"))
click.echo("")
click.echo(_("Repository configuration complete."))
except APIError as e:
_handle_http_error(e)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env python3
"""Generate self-contained SVG badge files from project metrics.
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
then writes SVG badge files that can be served as static files from
the Gitea raw file API.
Usage:
python3 scripts/generate_badges.py --output-dir .badges/
"""
from __future__ import annotations
import re
import subprocess # nosec B404
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent
INIT_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "__init__.py"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed")
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
# shields.io color names to hex values
COLOR_HEX: dict[str, str] = {
"brightgreen": "#4c1",
"green": "#97ca00",
"yellowgreen": "#a4a61d",
"yellow": "#dfb317",
"orange": "#fe7d37",
"red": "#e05d44",
"blue": "#007ec6",
"lightgrey": "#9f9f9f",
}
def _xml_escape(text: str) -> str:
"""Escape XML special characters."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def run_command(cmd: list[str]) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
)
return result.returncode, result.stdout, result.stderr
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
"""Build a badge data dict."""
return {
"schemaVersion": 1,
"label": label,
"message": message,
"color": color,
}
def render_svg(label: str, message: str, color: str) -> str:
"""Render a shields.io-style SVG badge."""
color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f")
# Approximate text width: 7px per character + 10px padding
label_text = _xml_escape(label)
message_text = _xml_escape(message)
label_w = max(len(label) * 7 + 10, 30)
message_w = max(len(message) * 7 + 10, 30)
total_w = label_w + message_w
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img"
aria-label="{label_text}: {message_text}">
<title>{label_text}: {message_text}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="{label_w}" height="20" fill="#555"/>
<rect x="{label_w}" width="{message_w}" height="20" fill="{color_hex}"/>
<rect width="{total_w}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="{label_w // 2}" y="14">{label_text}</text>
<text x="{label_w + message_w // 2}" y="14">{message_text}</text>
</g>
</svg>
'''
def extract_coverage(output: str) -> float | None:
"""Extract total coverage percentage from pytest-cov output."""
for line in output.splitlines():
match = _COVERAGE_RE.search(line)
if match:
return float(match.group(1))
return None
def extract_test_count(output: str) -> int | None:
"""Extract number of passed tests from pytest output."""
for line in output.splitlines():
match = _PASSED_RE.search(line)
if match:
return int(match.group(1))
return None
def extract_doc_coverage(output: str) -> int | None:
"""Extract doc coverage percentage from doc_coverage.py output."""
match = _DOC_COVERAGE_RE.search(output)
if match:
return int(match.group(1))
return None
def read_version() -> str:
"""Read __version__ from the package __init__.py."""
content = INIT_FILE.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
return "unknown"
def coverage_color(pct: float) -> str:
"""Map coverage percentage to a color."""
if pct >= 100:
return "brightgreen"
if pct >= 90:
return "green"
if pct >= 80:
return "yellowgreen"
if pct >= 70:
return "yellow"
if pct >= 60:
return "orange"
return "red"
def doc_coverage_color(pct: int) -> str:
"""Map doc coverage percentage to a color."""
if pct >= 100:
return "brightgreen"
if pct >= 90:
return "green"
if pct >= 80:
return "yellowgreen"
if pct >= 70:
return "yellow"
return "orange"
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
"""Generate all badge SVG files and return badge data as a dict."""
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run)
rc, stdout, stderr = run_command(
[
".venv/bin/pytest",
"tests/",
"-v",
"--cov=src/gitea_runner_manager",
"--cov=scripts",
"--cov-report=term-missing",
"--cov-fail-under=0",
]
)
combined = stdout + "\n" + stderr
coverage = extract_coverage(combined)
if coverage is not None:
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else:
badges["coverage"] = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined)
if test_count is not None:
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else:
badges["tests"] = make_badge("tests", "unknown", "red")
# 2. Documentation coverage
rc, stdout, _ = run_command(
[
".venv/bin/python3",
"scripts/ci/doc_coverage.py",
]
)
doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None:
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
else:
badges["docs"] = make_badge("docs", "unknown", "red")
# 3. Code quality (ruff + pyright + bandit all pass)
lint_rc, _, _ = run_command([".venv/bin/ruff", "check", "src/", "tests/", "scripts/"])
format_rc, _, _ = run_command([".venv/bin/ruff", "format", "--check", "src/", "tests/", "scripts/"])
type_rc, _, _ = run_command([".venv/bin/pyright"])
bandit_rc, _, _ = run_command([".venv/bin/bandit", "-r", "src/", "scripts/"])
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
# 4. Version
version = read_version()
badges["version"] = make_badge("version", f"v{version}", "blue")
# 5. Python version (static but nice)
badges["python"] = make_badge("python", "3.12", "blue")
# Write SVG files
output_dir.mkdir(parents=True, exist_ok=True)
for name, badge in badges.items():
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
path = output_dir / f"{name}.svg"
path.write_text(svg)
click.echo(f" Generated: {path}")
return badges
@click.command()
@click.option(
"--output-dir",
default=str(REPO_ROOT / ".badges"),
help="Directory to write badge SVG files.",
)
def cli(output_dir: str) -> None:
"""Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir)
click.echo(f"Generating badges in {out}...")
badges = generate_badges(out)
click.echo(f"\nGenerated {len(badges)} badges:")
for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
-323
View File
@@ -1,323 +0,0 @@
#!/usr/bin/env python3
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
This module provides a programmatic interface to the ``tea`` CLI tool,
parsing JSON output for structured data. It is used by CI scripts to
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
for reliability.
The wrapper requires ``tea`` to be installed and configured (run
``make setup`` which calls ``scripts/install_tools.py`` and
``scripts/setup.py``).
Operations supported via tea:
- Creating pull requests
- Creating issues
- Adding labels to issues/PRs
- Creating labels
- Merging pull requests
- Creating releases
- Posting reviews on PRs
- Listing branches
Operations NOT supported via tea (still use GiteaClient):
- Wiki page management
- Commit status checks
- Runner discovery
- PR file/commit listing (tea has limited support)
- Branch protection with detailed config (tea only has basic protect/unprotect)
Usage::
from scripts.gitea_cli import TeaCLI
tea = TeaCLI()
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
tea.add_label("owner/repo", 42, ["ready-to-merge"])
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
"""
from __future__ import annotations
import json
import shutil
import subprocess # nosec B404
from typing import Any
class TeaCLIError(Exception):
"""Raised when a tea CLI command fails."""
class TeaCLI:
"""Wrapper around the ``tea`` Gitea CLI tool.
All methods parse JSON output from tea for structured access.
Commands are run with ``--output json`` where structured data is expected.
"""
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
"""Initialize the tea CLI wrapper.
Args:
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
repo: Default repo in ``owner/name`` format for commands that need it.
"""
self._tea = tea_bin or shutil.which("tea") or "tea"
self._repo = repo
def _run(self, args: list[str], json_output: bool = True) -> str:
"""Run a tea command and return stdout.
Args:
args: Command arguments (without the leading ``tea``).
json_output: If True, append ``--output json`` to the command.
Returns:
stdout as a string.
Raises:
TeaCLIError: If the command fails.
"""
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise TeaCLIError(
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
)
return result.stdout.strip()
def _run_raw(self, args: list[str]) -> str:
"""Run a tea command without JSON output and return stdout."""
return self._run(args, json_output=False)
def _repo_arg(self, repo: str | None = None) -> list[str]:
"""Build the --repo argument list."""
target = repo or self._repo
if target:
return ["--repo", target]
return []
# -- Issues --
def create_issue(
self,
repo: str,
title: str,
body: str = "",
labels: list[str] | None = None,
) -> dict[str, Any]:
"""Create an issue and return the issue dict.
Args:
repo: Repository in ``owner/name`` format.
title: Issue title.
body: Issue body (markdown).
labels: List of label names to attach.
Returns:
The created issue as a dict (parsed from tea JSON output).
"""
args = ["issues", "create", "--title", title, "--body", body, *self._repo_arg(repo)]
output = self._run(args, json_output=False)
# tea issues create doesn't output JSON; extract issue number from output
# Format: "Created issue #42: <title>"
issue_index = _extract_issue_number(output)
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
# -- Labels --
def list_labels(self, repo: str) -> list[dict[str, Any]]:
"""List all labels for a repository."""
output = self._run(["labels", "list", *self._repo_arg(repo)])
if not output:
return []
return json.loads(output)
def create_label(
self,
repo: str,
name: str,
color: str = "",
description: str = "",
) -> dict[str, Any]:
"""Create a label. Returns the label dict.
Args:
repo: Repository in ``owner/name`` format.
name: Label name.
color: Hex color (without #), e.g. ``2ecc71``.
description: Label description.
"""
args = ["labels", "create", name, *self._repo_arg(repo)]
if color:
args.extend(["--color", f"#{color}"])
if description:
args.extend(["--description", description])
output = self._run(args, json_output=False)
return {"name": name, "color": color, "description": description, "output": output}
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
"""Add labels to an issue or PR.
Args:
repo: Repository in ``owner/name`` format.
issue_index: Issue or PR number.
labels: List of label names to add.
"""
for _label in labels:
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
return # tea edit handles all labels at once
# No labels to add — nothing to do
# -- Pull Requests --
def create_pr(
self,
repo: str,
title: str,
head: str,
base: str,
body: str = "",
) -> dict[str, Any]:
"""Create a pull request and return the PR dict.
Args:
repo: Repository in ``owner/name`` format.
title: PR title.
head: Head branch name.
base: Base branch name.
body: PR description (markdown).
"""
args = [
"pulls",
"create",
"--title",
title,
"--base",
base,
"--head",
head,
*self._repo_arg(repo),
]
if body:
args.extend(["--body", body])
output = self._run(args, json_output=False)
pr_index = _extract_pr_number(output)
return {"title": title, "index": pr_index, "url": output.strip()}
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
"""Merge a pull request.
Args:
repo: Repository in ``owner/name`` format.
pr_index: PR number.
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
"""
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
def review_pr(
self,
repo: str,
pr_index: int,
event: str = "COMMENT",
body: str = "",
) -> None:
"""Post a review on a pull request.
Args:
repo: Repository in ``owner/name`` format.
pr_index: PR number.
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
body: Review body text.
"""
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
if event == "APPROVE":
args.append("--approve")
elif event == "REQUEST_CHANGES":
args.extend(["--reject"])
if body:
args.extend(["--comment", body])
self._run_raw(args)
# -- Releases --
def create_release(
self,
repo: str,
tag: str,
title: str = "",
body: str = "",
target: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
"""Create a release and return the release dict.
Args:
repo: Repository in ``owner/name`` format.
tag: Tag name (e.g. ``v1.0.0``).
title: Release title.
body: Release notes (markdown).
target: Target branch/commit for the tag.
draft: If True, create as draft.
prerelease: If True, mark as prerelease.
"""
args = ["releases", "create", tag, *self._repo_arg(repo)]
if title:
args.extend(["--title", title])
if body:
args.extend(["--note", body])
if target:
args.extend(["--target", target])
if draft:
args.append("--draft")
if prerelease:
args.append("--prerelease")
output = self._run(args, json_output=False)
return {"tag": tag, "title": title, "url": output.strip()}
def list_releases(self, repo: str) -> list[dict[str, Any]]:
"""List all releases for a repository."""
output = self._run(["releases", "list", *self._repo_arg(repo)])
if not output:
return []
return json.loads(output)
# -- Branches --
def list_branches(self, repo: str) -> list[dict[str, Any]]:
"""List all branches for a repository."""
output = self._run(["branches", "list", *self._repo_arg(repo)])
if not output:
return []
return json.loads(output)
# -- Utility --
def whoami(self) -> str:
"""Return the current authenticated user."""
return self._run_raw(["whoami"])
def _extract_issue_number(output: str) -> int:
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
for part in output.split():
if part.startswith("#"):
try:
return int(part[1:].rstrip(":"))
except ValueError:
continue
return 0
def _extract_pr_number(output: str) -> int:
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
return _extract_issue_number(output)
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Install checkmake if it is not already present.
Tries to install via Go if available, otherwise downloads the latest
pre-built Linux binary from the official GitHub releases.
"""
from __future__ import annotations
import platform
import shutil
import subprocess # nosec B404
import urllib.request
from pathlib import Path
import click
CHECKMAKE_VERSION = "0.3.2"
RELEASE_URL_TEMPLATE = (
"https://github.com/checkmake/checkmake/releases/download/"
f"v{CHECKMAKE_VERSION}/checkmake-v{CHECKMAKE_VERSION}.linux.{{arch}}"
)
TARGET_PATH = Path("/usr/local/bin/checkmake")
def _arch() -> str:
"""Return the architecture string used by checkmake releases."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _install_with_go() -> bool:
"""Install checkmake using go install if Go is available."""
go_bin = shutil.which("go")
if go_bin is None:
return False
subprocess.run( # nosec B603
[
go_bin,
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
return True
def _download_binary() -> None:
"""Download the prebuilt checkmake binary for the current architecture."""
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
TARGET_PATH.chmod(0o755)
def main() -> None:
"""Install checkmake if not already present."""
if shutil.which("checkmake") is not None:
return
if not _install_with_go():
_download_binary()
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-219
View File
@@ -1,219 +0,0 @@
#!/usr/bin/env python3
"""Install CI/CD development tools that are not Python packages.
Handles installation of:
- actionlint (workflow YAML linter)
- git-cliff (changelog generator)
- act_runner (Gitea Actions local runner, optional)
- tea (Gitea CLI — official command-line tool for Gitea API operations)
Each tool is installed to ``~/.local/bin`` if not already on PATH.
Idempotent: skips tools that are already available.
Usage::
python3 scripts/install_tools.py # install all
python3 scripts/install_tools.py --tool actionlint # install one
python3 scripts/install_tools.py --list # list status
"""
from __future__ import annotations
import os
import platform
import shutil
import tarfile
import tempfile
import urllib.request
from pathlib import Path
import click
TARGET_DIR = Path.home() / ".local" / "bin"
ACTIONLINT_VERSION = "1.7.12"
GIT_CLIFF_VERSION = "2.13.0"
ACT_RUNNER_VERSION = "0.2.11"
TEA_VERSION = "0.14.1"
def _arch() -> str:
"""Return the architecture string used by release assets."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _ensure_target_dir() -> Path:
"""Ensure the target directory exists and return it."""
TARGET_DIR.mkdir(parents=True, exist_ok=True)
return TARGET_DIR
def _download(url: str, dest: Path) -> None:
"""Download a file from ``url`` to ``dest``."""
urllib.request.urlretrieve(url, dest) # nosec B310
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
Returns the path to the installed binary.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "archive.tar.gz"
_download(url, tarball)
with tarfile.open(tarball, "r:gz") as tar:
tar.extractall(tmpdir) # nosec B202
# Find the binary in the extracted tree
extracted = Path(tmpdir).rglob(binary_name)
found = next(extracted, None)
if found is None:
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
shutil.copy2(found, dest)
dest.chmod(0o755)
return dest
def _download_binary(url: str, binary_name: str) -> Path:
"""Download a standalone binary and install it to TARGET_DIR.
Returns the path to the installed binary.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
_download(url, dest)
dest.chmod(0o755)
return dest
def _is_installed(name: str) -> bool:
"""Check if a tool is already on PATH or in TARGET_DIR."""
if shutil.which(name) is not None:
return True
return (TARGET_DIR / name).exists()
def install_actionlint() -> bool:
"""Install actionlint if not already present. Returns True if installed/skipped."""
if _is_installed("actionlint"):
click.echo("actionlint: already installed")
return True
arch = _arch()
url = (
f"https://github.com/rhysd/actionlint/releases/download/"
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "actionlint")
click.echo(f"actionlint: installed to {dest}")
return True
def install_git_cliff() -> bool:
"""Install git-cliff if not already present. Returns True if installed/skipped."""
if _is_installed("git-cliff"):
click.echo("git-cliff: already installed")
return True
arch = _arch()
url = (
f"https://github.com/orhun/git-cliff/releases/download/"
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
)
dest = _download_and_extract_tarball(url, "git-cliff")
click.echo(f"git-cliff: installed to {dest}")
return True
def install_act_runner() -> bool:
"""Install act_runner if not already present. Returns True if installed/skipped."""
if _is_installed("act_runner"):
click.echo("act_runner: already installed")
return True
arch = _arch()
url = (
f"https://gitea.com/gitea/act_runner/releases/download/"
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
)
dest = _download_binary(url, "act_runner")
click.echo(f"act_runner: installed to {dest}")
return True
def install_tea() -> bool:
"""Install tea (Gitea CLI) if not already present. Returns True if installed/skipped."""
if _is_installed("tea"):
click.echo("tea: already installed")
return True
arch = _arch()
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
dest = _download_binary(url, "tea")
click.echo(f"tea: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
def _install_tool(name: str) -> bool:
"""Install a single tool by name."""
if name == "actionlint":
return install_actionlint()
if name == "git-cliff":
return install_git_cliff()
if name == "act_runner":
return install_act_runner()
if name == "tea":
return install_tea()
raise click.ClickException(f"Unknown tool: {name}")
def list_tools() -> None:
"""Print the installation status of all tools."""
for name in TOOL_NAMES:
status = "installed" if _is_installed(name) else "not installed"
click.echo(f" {name}: {status}")
@click.command()
@click.option(
"--tool",
"tools",
multiple=True,
type=click.Choice(TOOL_NAMES),
help="Install specific tool(s). Can be repeated.",
)
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
def main(tools: tuple[str, ...], list_status: bool) -> None:
"""Install CI/CD development tools to ~/.local/bin."""
if list_status:
list_tools()
return
tools_to_install = list(tools) if tools else TOOL_NAMES
failed: list[str] = []
for name in tools_to_install:
try:
_install_tool(name)
except Exception as exc:
click.echo(f" {name}: FAILED — {exc}", err=True)
failed.append(name)
if failed:
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
# Remind user to add ~/.local/bin to PATH if not already there
path_env = os.environ.get("PATH", "")
if str(TARGET_DIR) not in path_env:
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Run all molecule scenarios on all supported OS platforms.
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
Sequential execution — CI uses the parallel matrix instead.
Usage::
python3 scripts/molecule_all.py
python3 scripts/molecule_all.py --bin .venv/bin
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import sys
from pathlib import Path
import click
from scripts.ci.platforms import PLATFORMS
ROLE_DIR = Path("ansible/roles/gitea-runner")
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
"""Run a single molecule scenario. Returns the exit code."""
cmd = [molecule_bin, "test"]
if scenario != "default":
cmd.extend(["-s", scenario])
click.echo(f"--- Scenario: {scenario} ---")
result = subprocess.run( # nosec B603
cmd,
cwd=str(role_dir),
env=env,
)
return result.returncode
def _run_platform(
molecule_bin: str,
platform: dict[str, str],
role_dir: Path,
scenarios: list[str],
base_env: dict[str, str],
) -> int:
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
env = dict(base_env)
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
if platform.get("command"):
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
else:
env.pop("MOLECULE_PLATFORM_COMMAND", None)
click.echo(f"=== Platform: {platform['name']} ===")
for scenario in scenarios:
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
if rc != 0:
return rc
return 0
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
def main(bin_dir: str) -> None:
"""Run all molecule scenarios on all supported OS platforms sequentially."""
molecule_bin = str(Path(bin_dir) / "molecule")
if not Path(molecule_bin).exists():
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
if not ROLE_DIR.exists():
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
base_env = dict(os.environ)
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
for platform in PLATFORMS:
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
if rc != 0:
click.echo(f"FAILED on platform {platform['name']}", err=True)
sys.exit(rc)
click.echo("All molecule scenarios passed on all platforms.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Prune stale runner images from a rootless Docker daemon.
Usage:
python3 prune_runner_images.py [--dry-run]
Removes all images matching the runner-images pattern from the local
Docker daemon so the runner pulls a fresh :latest on the next job.
Environment variables:
DOCKER_HOST — Docker daemon socket (set by caller)
XDG_RUNTIME_DIR — Runtime directory (set by caller)
"""
from __future__ import annotations
import argparse
import re
import subprocess # nosec B404
import sys
from collections.abc import Sequence
#: Pattern for images we want to prune (repository:tag format).
IMAGE_PATTERN = re.compile(r"runner-images/(ci-base|ci-quality|ci-full)")
def list_docker_images() -> list[str]:
"""List all images in the local Docker daemon as repository:tag strings.
Returns:
List of ``repository:tag`` strings (excluding ``<none>`` entries).
"""
result = subprocess.run( # nosec B603
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip() and "<none>" not in line]
def filter_runner_images(images: Sequence[str]) -> list[str]:
"""Filter image list to only runner-images entries.
Args:
images: List of ``repository:tag`` strings.
Returns:
Subset matching the runner-images pattern.
"""
return [img for img in images if IMAGE_PATTERN.search(img)]
def remove_images(images: Sequence[str], dry_run: bool = False) -> list[str]:
"""Remove the given images from the local Docker daemon.
Args:
images: List of ``repository:tag`` strings to remove.
dry_run: If True, print what would be removed but don't execute.
Returns:
List of images that were removed (or would be removed in dry-run).
"""
removed: list[str] = []
for img in images:
if dry_run:
print(f"[dry-run] would remove: {img}")
removed.append(img)
continue
result = subprocess.run( # nosec B603
["docker", "rmi", "-f", img],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f"removed: {img}")
removed.append(img)
else:
print(f"failed to remove {img}: {result.stderr.strip()}", file=sys.stderr)
return removed
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Prune stale runner images.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be removed without executing.",
)
args = parser.parse_args(argv)
all_images = list_docker_images()
runner_images = filter_runner_images(all_images)
if not runner_images:
print("no runner images found to prune")
return 0
removed = remove_images(runner_images, dry_run=args.dry_run)
print(f"pruned {len(removed)} image(s)")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
Also configures the ``tea`` Gitea CLI login profile from ``.env`` so that
CI scripts and dev tools can use ``tea`` for Gitea API operations.
Usage::
python3 scripts/setup.py --bin .venv/bin
"""
from __future__ import annotations
import os
import shutil
import subprocess # nosec B404
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
load_dotenv(override=True)
def _run(cmd: list[str], bin_dir: str) -> None:
"""Run a command, streaming output to stdout/stderr."""
click.echo(f" $ {' '.join(cmd)}")
subprocess.run(cmd, check=True) # nosec B603
def _install_python_deps(bin_dir: str) -> None:
"""Install the project with dev extras in editable mode."""
pip = str(Path(bin_dir) / "pip")
_run([pip, "install", "-e", ".[dev]"], bin_dir)
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections."""
galaxy = str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)], bin_dir)
def _install_pre_commit_hooks(bin_dir: str) -> None:
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
pre_commit = str(Path(bin_dir) / "pre-commit")
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or REPO_TOKEN is not set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("tea: not installed — skipping login configuration.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
return
# Derive the Gitea URL from the API URL (strip /api/v1 suffix)
api_url = os.environ.get("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
gitea_url = api_url.replace("/api/v1", "")
login_name = "grm"
# Check if login already exists
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(f"tea: login '{login_name}' already configured.")
return
# Add login profile
click.echo(f"tea: configuring login '{login_name}' for {gitea_url}...")
add_result = subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
if add_result.returncode != 0:
click.echo(f"tea: login configuration failed: {add_result.stderr.strip()}", err=True)
else:
# Set as default login
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
click.echo(f"tea: login '{login_name}' configured and set as default.")
def _verify(bin_dir: str) -> None:
"""Print versions of installed tools for verification."""
grm = str(Path(bin_dir) / "grm")
pre_commit = str(Path(bin_dir) / "pre-commit")
for tool in [grm, pre_commit]:
try:
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
if result.returncode == 0:
click.echo(f" {result.stdout.strip()}")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
def main(bin_dir: str) -> None:
"""Install Python deps, Ansible collections, and pre-commit hooks."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo("Installing Python dependencies...")
_install_python_deps(bin_dir)
click.echo("Installing Ansible collections...")
_install_ansible_collections(bin_dir)
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
click.echo("Configuring tea CLI login...")
_configure_tea_login()
click.echo("")
click.echo("Setup complete.")
click.echo("Activate the virtual environment with one of:")
click.echo(" source .venv/bin/activate (generic)")
click.echo(" source activate.sh (bash)")
click.echo(" source activate.fish (fish)")
click.echo(" source activate.zsh (zsh)")
click.echo("")
_verify(bin_dir)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+144
View File
@@ -0,0 +1,144 @@
"""Tests for prune_runner_images.py."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from scripts.prune_runner_images import (
filter_runner_images,
list_docker_images,
main,
remove_images,
)
class TestListDockerImages:
"""Tests for list_docker_images()."""
@patch("scripts.prune_runner_images.subprocess.run")
def test_returns_images_from_docker(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="repo1:tag1\nrepo2:tag2\n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo1:tag1", "repo2:tag2"]
@patch("scripts.prune_runner_images.subprocess.run")
def test_filters_none_entries(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="repo:tag\n<none>:<none>\nother:v1\n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo:tag", "other:v1"]
@patch("scripts.prune_runner_images.subprocess.run")
def test_empty_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = list_docker_images()
assert result == []
@patch("scripts.prune_runner_images.subprocess.run")
def test_strips_whitespace(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout=" repo:tag \n\n other:v2 \n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo:tag", "other:v2"]
class TestFilterRunnerImages:
"""Tests for filter_runner_images()."""
def test_matches_runner_images(self) -> None:
images = [
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest",
]
result = filter_runner_images(images)
assert len(result) == 3
def test_excludes_non_runner_images(self) -> None:
images = [
"docker.io/library/python:3.12",
"docker.io/library/nginx:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest",
]
result = filter_runner_images(images)
assert len(result) == 1
assert "ci-base" in result[0]
def test_empty_list(self) -> None:
assert filter_runner_images([]) == []
def test_no_matches(self) -> None:
images = ["python:3.12", "nginx:latest"]
assert filter_runner_images(images) == []
class TestRemoveImages:
"""Tests for remove_images()."""
@patch("scripts.prune_runner_images.subprocess.run")
def test_removes_images(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
images = ["repo/ci-base:latest", "repo/ci-quality:latest"]
removed = remove_images(images)
assert removed == images
assert mock_run.call_count == 2
@patch("scripts.prune_runner_images.subprocess.run")
def test_dry_run_does_not_call_docker(self, mock_run: MagicMock) -> None:
images = ["repo/ci-base:latest"]
removed = remove_images(images, dry_run=True)
assert removed == images
mock_run.assert_not_called()
@patch("scripts.prune_runner_images.subprocess.run")
def test_failed_removal_not_in_result(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="image in use")
images = ["repo/ci-base:latest"]
removed = remove_images(images)
assert removed == []
@patch("scripts.prune_runner_images.subprocess.run")
def test_empty_list(self, mock_run: MagicMock) -> None:
removed = remove_images([])
assert removed == []
mock_run.assert_not_called()
class TestMain:
"""Tests for main()."""
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_no_images(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = []
assert main([]) == 0
mock_remove.assert_not_called()
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_with_images(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = [
"repo/runner-images/ci-base:latest",
"python:3.12",
]
mock_remove.return_value = ["repo/runner-images/ci-base:latest"]
assert main([]) == 0
mock_remove.assert_called_once()
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_dry_run_flag(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = ["repo/runner-images/ci-base:latest"]
mock_remove.return_value = ["repo/runner-images/ci-base:latest"]
assert main(["--dry-run"]) == 0
mock_remove.assert_called_once_with(
["repo/runner-images/ci-base:latest"],
dry_run=True,
)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.6.0"
__version__ = "0.10.3"
+54 -4
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import functools
import os
import sys
from collections.abc import Callable
from typing import Any
@@ -77,8 +78,8 @@ def cli() -> None:
@click.option(
"--admin-token",
"-a",
default=lambda: os.getenv("REPO_TOKEN"),
help=_("Gitea admin API token for integration test (env: REPO_TOKEN)"),
default=lambda: os.getenv("CI_GITEA_TOKEN"),
help=_("Gitea admin API token for integration test (env: CI_GITEA_TOKEN)"),
)
@click.option(
"--integration-retries",
@@ -203,6 +204,27 @@ def stop(
)
@cli.command(help=_("Restart a registered Gitea Runner (stop, prune images, start)."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Restart failed: {error}")
def restart(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.restart(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Enable a registered Gitea Runner to start on boot."))
@click.argument("runner_name")
@_runner_options
@@ -325,11 +347,39 @@ def remove(
)
def _collect_become_pass(ask_become_pass: bool) -> str | None:
"""Collect sudo password for ad-hoc status checks.
When stdin is a TTY, prompts interactively with hidden input.
When stdin is piped, reads the first line (e.g. ``echo 'pass' | grm list``).
"""
if not ask_become_pass:
return None
if sys.stdin.isatty():
return (
click.prompt(
_("Sudo password"),
hide_input=True,
default="",
show_default=False,
)
or None
)
return sys.stdin.readline().strip() or None
@cli.command(name="list", help=_("List all registered runners with live status."))
@click.option(
"--ask-become-pass/--no-ask-become-pass",
default=True,
help=_("Prompt for sudo password once for all status checks (default)."),
)
@_handle_errors("List failed: {error}")
def list_runners() -> None:
def list_runners(ask_become_pass: bool) -> None:
become_pass = _collect_become_pass(ask_become_pass)
manager = RunnerManager()
runners = manager.list_runners()
runners = manager.list_runners(become_pass=become_pass)
if not runners:
click.echo(_("No runners registered. Use 'grm install' to add one."))
+4 -8
View File
@@ -21,13 +21,15 @@ DEFAULT_PER_PAGE = 50
BRANCH_PROTECTION_CONFIG: dict[str, object] = {
"branch_name": "master",
"enable_push": False,
"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 (0) (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,
@@ -36,12 +38,6 @@ BRANCH_PROTECTION_CONFIG: dict[str, object] = {
"block_on_official_review_requests": True,
}
LABEL_CONFIG: dict[str, object] = {
"name": "ready-to-merge",
"color": "2ecc71",
"description": "Auto-merge PR when all CI checks pass",
}
REPO_SETTINGS_CONFIG: dict[str, object] = {
"default_delete_branch_after_merge": True,
}
+32 -10
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
import contextlib
import logging
import os
import re
import subprocess # nosec B404
import sys
import tempfile
from datetime import datetime
from pathlib import Path
@@ -88,8 +89,14 @@ class AnsibleExecutor:
become: bool = False,
ask_become_pass: bool = False,
check: bool = True,
become_pass: str | None = None,
) -> str:
"""Run an Ansible ad-hoc command and return stdout."""
"""Run an Ansible ad-hoc command and return stdout.
When ``become_pass`` is provided, it is passed via a temporary file
(``--become-password-file``) to avoid stdin consumption issues when
running multiple ad-hoc commands in sequence (e.g. ``grm list``).
"""
cmd = [
"ansible",
host,
@@ -104,16 +111,31 @@ class AnsibleExecutor:
cmd.extend(["--private-key", key])
if become:
cmd.append("--become")
if become and ask_become_pass and sys.stdin.isatty():
cmd.append("--ask-become-pass")
password_file: str | None = None
if become and ask_become_pass:
if become_pass:
fd, password_file = tempfile.mkstemp(suffix=".txt", prefix="grm-become-")
with os.fdopen(fd, "w") as f:
f.write(become_pass)
os.chmod(password_file, 0o600)
cmd.extend(["--become-password-file", password_file])
else:
cmd.append("--ask-become-pass")
env = os.environ.copy()
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
try:
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
finally:
if password_file:
with contextlib.suppress(FileNotFoundError):
os.unlink(password_file)
if check and proc.returncode != 0:
stderr = proc.stderr.strip() if proc.stderr else ""
raise AnsibleError(
+2 -2
View File
@@ -1,7 +1,7 @@
"""Simple i18n for GRM console messages.
Set GRM_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
Supported: en, bg, de, ru, zh, pl.
"""
from __future__ import annotations
@@ -22,7 +22,7 @@ def _(key: str, **kwargs: object) -> str:
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("GRM_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
+35 -3
View File
@@ -226,6 +226,29 @@ class RunnerManager:
)
tracker.done()
def restart(
self,
name: str,
host: str | None = None,
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
) -> None:
"""Restart a runner instance on a remote host (stop, prune images, start)."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
with track_steps() as tracker:
tracker.begin(_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host))
self._run_playbook(
"restart-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
actual_key,
ask_become_pass,
description=_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host),
)
tracker.done()
def enable(
self,
name: str,
@@ -345,8 +368,14 @@ class RunnerManager:
self._registry.remove(name)
tracker.done()
def list_runners(self) -> list[dict[str, str]]:
"""Return a list of registered runners with live service status."""
def list_runners(self, become_pass: str | None = None) -> list[dict[str, str]]:
"""Return a list of registered runners with live service status.
Args:
become_pass: Sudo password for ad-hoc status checks. When provided,
it is passed via ``--become-password-file`` to avoid stdin
consumption issues when checking multiple runners in sequence.
"""
runners = self._registry.list()
result: list[dict[str, str]] = []
for name, info in runners.items():
@@ -369,10 +398,13 @@ class RunnerManager:
user,
key,
"shell",
f"sudo -u grm-{name} systemctl --user is-active gitea-runner 2>/dev/null",
f"sudo -u grm-{name} "
f"XDG_RUNTIME_DIR=/run/user/$(id -u grm-{name}) "
f"systemctl --user is-active gitea-runner 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
become_pass=become_pass,
)
service_status = self._parse_status(stdout)
except AnsibleError:
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -556,7 +556,8 @@ class TestVikunjaClient:
json={"done": True},
)
def test_http_error_raises_api_error(self) -> None:
@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")
-670
View File
@@ -1,670 +0,0 @@
"""Unit tests for scripts/ci/auto_merge.py."""
import http
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError
from scripts.ci.auto_merge import (
PR_TITLE_RE,
extract_conventional_msg,
extract_task_id,
has_approval_review,
has_ready_to_merge_label,
main,
run_cmd,
validate_pr_title,
validate_pr_title_matches_vikunja,
wait_for_ci,
)
CI_SUCCESS = "success"
CI_PENDING = "pending"
CI_FAILURE = "failure"
def _status(context: str, status: str, updated_at: str = "2026-01-01T00:00:00Z") -> dict[str, str]:
return {"context": context, "status": status, "updated_at": updated_at}
def _commit(message: str) -> dict[str, dict[str, str]]:
return {"commit": {"message": message}}
class TestRegexes:
def test_task_id_re_matches(self) -> None:
assert TASK_ID_RE.search("GRM-19-fix-bug")
assert TASK_ID_RE.search("feature/GRM-42")
def test_task_id_re_no_match(self) -> None:
assert not TASK_ID_RE.search("feature-no-id")
def test_conventional_re_matches(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(api): handle timeout")
def test_conventional_re_rejects(self) -> None:
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
def test_pr_title_re_matches(self) -> None:
assert PR_TITLE_RE.match("GRM-19: Some task title")
assert PR_TITLE_RE.match("GRM-42: double space title")
def test_pr_title_re_rejects(self) -> None:
assert not PR_TITLE_RE.match("fix: resolve timeout")
assert not PR_TITLE_RE.match("GRM-19:No space after colon")
assert not PR_TITLE_RE.match("random message")
class TestExtractTaskId:
def test_extracts_from_branch(self) -> None:
assert extract_task_id("GRM-19-fix-bug") == "GRM-19"
def test_extracts_from_feature_branch(self) -> None:
assert extract_task_id("feature/GRM-42-add-x") == "GRM-42"
def test_returns_empty_when_missing(self) -> None:
assert extract_task_id("feature-no-id") == ""
class TestValidatePrTitle:
def test_valid_title_passes(self) -> None:
validate_pr_title("GRM-19: Some task title", "GRM-19")
def test_valid_title_with_scope_passes(self) -> None:
validate_pr_title("GRM-42: Add --url option", "GRM-42")
def test_invalid_format_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title("random message", "GRM-19")
assert "GRM-N" in str(exc.value)
def test_invalid_format_conventional_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title("fix: resolve timeout", "GRM-19")
assert "GRM-N" in str(exc.value)
def test_task_id_mismatch_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title("GRM-42: Some task title", "GRM-19")
assert "mismatch" in str(exc.value)
class TestExtractConventionalMsg:
def test_returns_newest_conventional(self) -> None:
"""Iterates in reverse — picks the newest conventional commit."""
commits = [
_commit("fix: resolve timeout"),
_commit("random message"),
_commit("feat: add thing"),
]
assert extract_conventional_msg(commits) == "feat: add thing"
def test_falls_back_to_newest_commit(self) -> None:
commits = [
_commit("another random"),
_commit("random message"),
]
assert extract_conventional_msg(commits) == "random message"
def test_uses_first_line_only(self) -> None:
commits = [_commit("fix: resolve timeout\n\nBody text here")]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_empty_commits_returns_empty(self) -> None:
assert extract_conventional_msg([]) == ""
def test_commit_with_scope(self) -> None:
commits = [_commit("feat(api): new endpoint")]
assert extract_conventional_msg(commits) == "feat(api): new endpoint"
def test_missing_commit_key(self) -> None:
commits = [{}] # type: ignore[list-item]
assert extract_conventional_msg(commits) == ""
class TestHasReadyToMergeLabel:
def test_label_present(self) -> None:
client = MagicMock()
client.get_pr_labels.return_value = [{"name": "bug"}, {"name": "ready-to-merge"}]
assert has_ready_to_merge_label(client, "5") is True
def test_label_absent(self) -> None:
client = MagicMock()
client.get_pr_labels.return_value = [{"name": "bug"}]
assert has_ready_to_merge_label(client, "5") is False
def test_no_labels(self) -> None:
client = MagicMock()
client.get_pr_labels.return_value = []
assert has_ready_to_merge_label(client, "5") is False
class TestHasApprovalReview:
def test_has_substantive_approved(self) -> None:
client = MagicMock()
client.get_pr_reviews.return_value = [
{"state": "APPROVED", "body": "All comments addressed. LGTM.", "comments": []},
{"state": "COMMENT"},
]
assert has_approval_review(client, "5") is True
def test_has_approved_with_inline_comments(self) -> None:
client = MagicMock()
client.get_pr_reviews.return_value = [
{"state": "APPROVED", "body": "", "comments": [{"body": "good"}]},
]
assert has_approval_review(client, "5") is True
def test_trivial_approved_falls_back_to_no_changes(self) -> None:
"""A bare 'LGTM' approval (< 20 chars) without comments falls back to
checking no REQUEST_CHANGES exist (single-token workflow)."""
client = MagicMock()
client.get_pr_reviews.return_value = [
{"state": "APPROVED", "body": "LGTM", "comments": []},
]
assert has_approval_review(client, "5") is True
def test_no_approved_but_no_changes_requested(self) -> None:
"""Single-token workflow: no APPROVE but no REQUEST_CHANGES either."""
client = MagicMock()
client.get_pr_reviews.return_value = [{"state": "COMMENT"}]
assert has_approval_review(client, "5") is True
def test_changes_requested_blocks_merge(self) -> None:
"""REQUEST_CHANGES blocks merge even in single-token workflow."""
client = MagicMock()
client.get_pr_reviews.return_value = [{"state": "REQUEST_CHANGES", "body": "Fix this"}]
assert has_approval_review(client, "5") is False
def test_no_reviews_allows_merge(self) -> None:
"""No reviews at all allows merge (single-token workflow fallback)."""
client = MagicMock()
client.get_pr_reviews.return_value = []
assert has_approval_review(client, "5") is True
class TestValidatePrTitleMatchesVikunja:
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="")
@patch.dict("os.environ", {}, clear=True)
def test_no_vikunja_token_skips(self, mock_get: MagicMock) -> None:
"""Should skip validation when VIKUNJA_TOKEN is not set."""
validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19")
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_matching_title_passes(self, mock_get: MagicMock) -> None:
validate_pr_title_matches_vikunja("GRM-19: Some task title", "GRM-19")
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_mismatched_title_raises(self, mock_get: MagicMock) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title_matches_vikunja("GRM-19: Different title", "GRM-19")
assert "does not match" in str(exc.value)
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_task_not_found_skips(self, mock_get: MagicMock) -> None:
"""Should skip validation when Vikunja task is not found."""
validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19")
class TestGetVikunjaTaskTitle:
@patch.dict("os.environ", {}, clear=True)
def test_no_token_returns_empty(self) -> None:
from scripts.ci.auto_merge import get_vikunja_task_title
assert get_vikunja_task_title("GRM-19") == ""
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_finds_task(self, mock_client_cls: MagicMock) -> None:
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"identifier": "GRM-19", "title": "Some task title"},
]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-19") == "Some task title"
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_task_not_found_returns_empty(self, mock_client_cls: MagicMock) -> None:
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"identifier": "GRM-20", "title": "Other task"},
]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-19") == ""
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_paginates_to_find_task(self, mock_client_cls: MagicMock) -> None:
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
# First page: full page of 50 tasks, no match; second page: match
page1 = [{"identifier": f"GRM-{i}", "title": f"task {i}"} for i in range(50)]
page2 = [{"identifier": "GRM-99", "title": "Found task"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-99") == "Found task"
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_empty_pages_returns_empty(self, mock_client_cls: MagicMock) -> None:
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-19") == ""
class TestWaitForCi:
def test_all_pass_immediately(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / molecule-tests (0) (pull_request)", CI_SUCCESS),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_waits_then_passes(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[_status("CI / quality (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_fails_on_failed_check(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / molecule-tests (0) (pull_request)", CI_FAILURE),
]
assert wait_for_ci(client, "abc123", max_wait=10) is False
def test_times_out(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING),
]
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=5) is False
def test_no_statuses_waits(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_ignores_non_ci_contexts(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("Auto-merge / merge (pull_request)", CI_PENDING),
_status("CI / quality (pull_request)", CI_SUCCESS),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_deduplicates_by_latest(self) -> None:
"""Combined endpoint returns one entry per context; if multiple
entries appear, the last one wins (dict comprehension)."""
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING, "2026-01-01T00:00:00Z"),
_status("CI / quality (pull_request)", CI_SUCCESS, "2026-01-01T00:01:00Z"),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_skipped_jobs_count_as_passing(self) -> None:
"""Conditional jobs that are skipped should not block merge."""
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / badges (pull_request)", "skipped"),
_status("CI / molecule-tests (pull_request)", "skipped"),
_status("CI / discover-runners (pull_request)", "skipped"),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_only_non_ci_contexts_waits_then_ci_appears(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[_status("Auto-merge / merge (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def _mock_pr(sha: str = "abc123def456") -> dict[str, object]:
return {"head": {"sha": sha}}
def _mock_ci_passing() -> list[dict[str, str]]:
return [_status("CI / quality (pull_request)", CI_SUCCESS)]
def _mock_commits() -> list[dict[str, dict[str, str]]]:
return [_commit("fix: resolve timeout")]
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_successful_flow_with_label_arg(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "ready-to-merge"],
)
assert result.exit_code == 0
assert "squash-merged" in result.output
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_successful_flow_label_fallback(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""Label not passed via arg, but PR has ready-to-merge via API."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7"],
)
assert result.exit_code == 0
assert "squash-merged" in result.output
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_wrong_label_skips_merge(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""Label is not ready-to-merge and PR doesn't have it via API either."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "bug"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "bug"],
)
assert result.exit_code == 0
assert "skipping" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_empty_label_falls_back_to_api(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""Gitea Actions doesn't populate label name, but API shows ready-to-merge."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", ""],
)
assert result.exit_code == 0
assert "squash-merged" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["branch", "title", "owner/repo", "1"])
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.GiteaClient")
def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["feature-no-id", "GRM-19: bug", "owner/repo", "1"])
assert result.exit_code == 1
assert "task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "random title", "owner/repo", "1"])
assert result.exit_code == 1
assert "GRM-N" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.GiteaClient")
def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None:
"""PR title has a different task ID than the branch."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-42: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "mismatch" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=False)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_no_approval_review_blocks_merge(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""No APPROVE review — merge should be blocked."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "APPROVE review" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_empty_commits_exits(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""PR has no commits — cannot extract conventional message."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "conventional commit" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_pr_failure_raises_click(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "HTTP" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_pr_json_parse_failure(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_ci_failure_blocks_merge(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""CI checks fail — merge should not be attempted."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_FAILURE),
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "CI checks did not pass" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_no_sha_proceeds_without_wait(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""PR head SHA missing — should proceed without waiting."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = {"head": {}}
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 0
assert "squash-merged" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_405_behind_retries(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""405 'behind' error should trigger rebase and retry."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
# First merge_pr raises 405 "behind", second succeeds
mock_client.merge_pr.side_effect = [
APIError(http.HTTPStatus.METHOD_NOT_ALLOWED, "head branch is behind base"),
None,
]
mock_client_cls.return_value = mock_client
with patch("scripts.ci.auto_merge.run_cmd") as mock_run_cmd:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 0
assert "Rebased" in result.output or "rebase" in result.output.lower()
assert mock_client.merge_pr.call_count == 2
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_405_behind_rebase_fails(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
"""405 'behind' with rebase failure should raise ClickException."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.METHOD_NOT_ALLOWED, "head branch is behind base")
mock_client_cls.return_value = mock_client
with patch("scripts.ci.auto_merge.run_cmd") as mock_run_cmd:
mock_run_cmd.side_effect = click.ClickException("rebase failed")
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "rebase" in result.output.lower() or "retry" in result.output.lower()
def test_run_cmd_success(self) -> None:
"""run_cmd should return CompletedProcess on success."""
with patch("scripts.ci.auto_merge.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
result = run_cmd(["echo", "ok"])
assert result.returncode == 0
def test_run_cmd_failure_raises(self) -> None:
"""run_cmd should raise ClickException on non-zero exit."""
with patch("scripts.ci.auto_merge.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
with pytest.raises(click.ClickException):
run_cmd(["false"])
-140
View File
@@ -1,140 +0,0 @@
"""Unit tests for scripts/check_test_speed.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from scripts.check_test_speed import (
DEFAULT_MAX_SECONDS,
TEST_COMMAND,
check_speed,
cli,
parse_duration,
run_tests,
)
class TestRunTests:
@patch("scripts.check_test_speed.subprocess.run")
def test_run_tests_returns_stdout_stderr(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
stdout, stderr = run_tests()
assert stdout == "out"
assert stderr == "err"
mock_run.assert_called_once_with(
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
class TestParseDuration:
def test_parses_valid_line(self) -> None:
assert parse_duration("234 passed in 0.70s") == 0.70
def test_parses_with_warnings(self) -> None:
assert parse_duration("293 passed, 1 warning in 0.45s") == 0.45
def test_parses_multiline_output(self) -> None:
output = "some header\n234 passed in 1.23s\nfooter"
assert parse_duration(output) == 1.23
def test_raises_when_no_timing_line(self) -> None:
with pytest.raises(click.ClickException) as exc:
parse_duration("no timing here")
assert "Could not parse" in str(exc.value)
class TestCheckSpeed:
def test_under_budget_passes(self) -> None:
check_speed(1.0, 2.0) # should not raise
def test_exact_budget_passes(self) -> None:
check_speed(2.0, 2.0) # should not raise
def test_over_budget_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
check_speed(2.1, 2.0)
msg = str(exc.value)
assert "too slow" in msg.lower()
assert "2.10s" in msg
assert "max allowed: 2.0s" in msg
def test_main_module_block() -> None:
import scripts.check_test_speed as cts
with patch.object(cts, "cli") as mock_cli:
with patch.object(cts, "__name__", "__main__"):
cts.cli([])
mock_cli.assert_called_once_with([])
class TestMain:
@patch("scripts.check_test_speed.run_tests")
@patch("scripts.check_test_speed.parse_duration")
@patch("scripts.check_test_speed.check_speed")
def test_successful_run(
self,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "1.50s" in result.output
assert "under 2.0s limit" in result.output
mock_run.assert_called_once()
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
@patch("scripts.check_test_speed.run_tests")
@patch("scripts.check_test_speed.parse_duration")
def test_slow_tests_exit(
self,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "too slow" in result.output.lower()
@patch("scripts.check_test_speed.run_tests")
def test_parse_failure_exits(
self,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("bad output\n", "")
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "Could not parse" in result.output
@patch("scripts.check_test_speed.run_tests")
@patch("scripts.check_test_speed.parse_duration")
@patch("scripts.check_test_speed.check_speed")
def test_custom_max_seconds(
self,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5
runner = CliRunner()
result = runner.invoke(cli, ["--max-seconds", "1.5"])
assert result.exit_code == 0
mock_check.assert_called_once_with(0.5, 1.5)
-373
View File
@@ -1,373 +0,0 @@
"""Unit tests for scripts/ci/classify_changes.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
import scripts.ci.classify_changes as classify_changes_mod
from scripts.ci.classify_changes import (
classify_changes,
get_changed_files,
get_latest_tag,
has_user_facing_changes,
is_user_facing,
is_workflow_only,
main,
run_git,
)
class TestIsUserFacing:
def test_src_is_user_facing(self) -> None:
assert is_user_facing("src/gitea_runner_manager/cli.py") is True
def test_ansible_is_user_facing(self) -> None:
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
def test_pyproject_is_user_facing(self) -> None:
assert is_user_facing("pyproject.toml") is True
def test_workflow_is_not_user_facing(self) -> None:
assert is_user_facing(".gitea/workflows/ci.yml") is False
def test_ci_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/ci/release.py") is False
def test_dev_scripts_are_not_user_facing(self) -> None:
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
User-facing code lives in src/gitea_runner_manager/."""
assert is_user_facing("scripts/check_test_speed.py") is False
assert is_user_facing("scripts/configure_repo.py") is False
assert is_user_facing("scripts/install_checkmake.py") is False
def test_shell_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/setup.sh") is False
assert is_user_facing("scripts/molecule_all.sh") is False
def test_scripts_init_is_not_user_facing(self) -> None:
assert is_user_facing("scripts/__init__.py") is False
def test_version_file_is_not_user_facing(self) -> None:
"""__init__.py only contains __version__ — a release artifact,
not user-facing code. Version bumps alone should not trigger releases."""
assert is_user_facing("src/gitea_runner_manager/__init__.py") is False
def test_api_clients_is_not_user_facing(self) -> None:
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
assert is_user_facing("src/gitea_runner_manager/api_clients.py") is False
def test_review_checklist_is_not_user_facing(self) -> None:
"""REVIEW_CHECKLIST.md is agent infrastructure, not user-facing."""
assert is_user_facing("REVIEW_CHECKLIST.md") is False
def test_docs_are_not_user_facing(self) -> None:
assert is_user_facing("docs/user/getting-started.md") is False
def test_tests_are_not_user_facing(self) -> None:
assert is_user_facing("tests/unit/test_cli.py") is False
def test_agents_md_is_not_user_facing(self) -> None:
assert is_user_facing("AGENTS.md") is False
def test_makefile_is_not_user_facing(self) -> None:
assert is_user_facing("Makefile") is False
def test_unknown_file_defaults_to_user_facing(self) -> None:
"""Safe default: unknown files are user-facing (require release)."""
assert is_user_facing("some/new/file.type") is True
assert is_user_facing("new_root_file.txt") is True
def test_is_workflow_only_inverse(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
assert is_workflow_only("src/gitea_runner_manager/cli.py") is False
assert is_workflow_only("pyproject.toml") is False
class TestClassifyChanges:
def test_all_user_facing(self) -> None:
files = ["src/gitea_runner_manager/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
result = classify_changes(files)
assert result["user_facing"] == files
assert result["workflow_only"] == []
def test_all_workflow_only(self) -> None:
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
result = classify_changes(files)
assert result["user_facing"] == []
assert result["workflow_only"] == files
def test_mixed(self) -> None:
files = [
"src/gitea_runner_manager/cli.py",
".gitea/workflows/ci.yml",
"pyproject.toml",
"docs/index.md",
]
result = classify_changes(files)
assert "src/gitea_runner_manager/cli.py" in result["user_facing"]
assert "pyproject.toml" in result["user_facing"]
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
assert "docs/index.md" in result["workflow_only"]
def test_empty(self) -> None:
result = classify_changes([])
assert result == {"user_facing": [], "workflow_only": []}
class TestGetChangedFiles:
@patch("scripts.ci.classify_changes.run_git")
def test_returns_file_list(self, mock_run_git: MagicMock) -> None:
mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md"
result = get_changed_files("v0.1.0", "HEAD")
assert result == ["file1.py", "file2.py", "file3.md"]
@patch("scripts.ci.classify_changes.run_git")
def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None:
mock_run_git.return_value = ""
result = get_changed_files("v0.1.0", "HEAD")
assert result == []
class TestHasUserFacingChanges:
@patch("scripts.ci.classify_changes.get_changed_files")
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
mock_get.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
assert has_user_facing_changes("v0.1.0", "HEAD") is True
@patch("scripts.ci.classify_changes.get_changed_files")
def test_false_when_workflow_only(self, mock_get: MagicMock) -> None:
mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
assert has_user_facing_changes("v0.1.0", "HEAD") is False
@patch("scripts.ci.classify_changes.get_changed_files")
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
assert has_user_facing_changes("v0.1.0", "HEAD") is False
class TestGetLatestTag:
@patch("subprocess.run")
def test_returns_tag(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="")
assert get_latest_tag() == "v0.3.0"
@patch("subprocess.run")
def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
assert get_latest_tag() == ""
class TestRunGit:
@patch("scripts.ci.classify_changes.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="")
result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"])
assert result == "file1.py"
@patch("scripts.ci.classify_changes.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error")
with pytest.raises(click.ClickException):
run_git(["git", "bad-command"])
class TestMain:
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.ci.classify_changes.get_changed_files", return_value=[])
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
assert "no release needed" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
"""Non-quiet mode with no tags prints user-facing message."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No tags found" in result.output
@patch("scripts.ci.classify_changes.get_changed_files", return_value=[])
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Non-quiet mode with no changes prints message."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No changes" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Quiet mode with user-facing changes outputs true."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Quiet mode with workflow-only changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Explicit --base overrides latest tag."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with Ansible changes outputs true."""
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with no Ansible changes outputs false."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with user-facing changes outputs true."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with only workflow changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible in non-quiet mode prints file list."""
mock_changes.return_value = ["ansible/tasks/main.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"])
assert result.exit_code == 0
assert "Ansible changes detected" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing in non-quiet mode prints file list."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing"])
assert result.exit_code == 0
assert "User-facing changes detected" in result.output
class TestGithubOutput:
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code != 0
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
+49 -5
View File
@@ -7,6 +7,8 @@ from click.testing import CliRunner
from gitea_runner_manager import __version__
from gitea_runner_manager.cli import cli
_TEST_ENV = {"GITEA_URL": "https://git.example.com", "CI_GITEA_TOKEN": "", "GITEA_RUNNER_LABELS": ""}
class TestCLI:
def test_cli_version(self) -> None:
@@ -20,7 +22,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
@@ -41,7 +43,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--no-ask-become-pass"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
@@ -115,7 +117,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(
cli,
[
@@ -150,7 +152,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--ask-become-pass"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
@@ -284,6 +286,22 @@ class TestCLI:
ask_become_pass=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_restart(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["restart", "r1"])
assert result.exit_code == 0
mock_manager.restart.assert_called_once_with(
name="r1",
host=None,
user=None,
key=None,
ask_become_pass=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_enable(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
@@ -537,7 +555,33 @@ class TestCLI:
assert "r1" in result.output
assert "10.0.0.1" in result.output
assert "active" in result.output
mock_manager.list_runners.assert_called_once()
mock_manager.list_runners.assert_called_once_with(become_pass=None)
@patch("gitea_runner_manager.cli.click.prompt", return_value="secret")
@patch("gitea_runner_manager.cli.sys.stdin")
def test_collect_become_pass_tty(self, mock_stdin: MagicMock, mock_prompt: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass
mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=True) == "secret"
@patch("gitea_runner_manager.cli.sys.stdin")
def test_collect_become_pass_no_ask(self, mock_stdin: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass
mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=False) is None
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_with_piped_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.list_runners.return_value = []
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["list"], input="secret\n")
assert result.exit_code == 0
mock_manager.list_runners.assert_called_once_with(become_pass="secret")
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_empty(self, mock_manager_class: MagicMock) -> None:
+3 -6
View File
@@ -6,7 +6,6 @@ from gitea_runner_manager.config import (
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
LABEL_CONFIG,
REPO_NAME,
REPO_OWNER,
TASK_ID_RE,
@@ -46,14 +45,12 @@ class TestConfigConstants:
def test_branch_protection_config(self) -> None:
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
assert BRANCH_PROTECTION_CONFIG["enable_push"] is False
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)
def test_label_config(self) -> None:
assert LABEL_CONFIG["name"] == "ready-to-merge"
assert LABEL_CONFIG["color"] == "2ecc71"
-179
View File
@@ -1,179 +0,0 @@
"""Unit tests for scripts/configure_repo.py."""
import http
from unittest.mock import MagicMock, patch
import click
import pytest
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG, REPO_SETTINGS_CONFIG
from gitea_runner_manager.exceptions import APIError
from scripts.configure_repo import (
_ensure_label_via_client,
_ensure_label_via_tea,
_handle_http_error,
main,
)
from scripts.gitea_cli import TeaCLIError
class TestHandleHttpError:
def test_handle_http_error_403(self) -> None:
err = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
msg = str(exc.value)
assert "admin rights" in msg
assert "Settings → Branches" in msg
def test_handle_http_error_other(self) -> None:
err = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "Internal Server Error")
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert str(http.HTTPStatus.INTERNAL_SERVER_ERROR) in str(exc.value)
def test_handle_http_error_json_parse_fails(self) -> None:
err = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc.value)
class TestEnsureLabelViaTea:
def test_creates_new_label(self) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"name": "bug"}]
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
assert result is True
mock_tea.create_label.assert_called_once()
def test_label_already_exists(self) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"name": "ready-to-merge"}]
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
assert result is False
mock_tea.create_label.assert_not_called()
def test_tea_error_falls_back_to_client(self) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
with patch("scripts.configure_repo._ensure_label_via_client", return_value=True) as mock_fallback:
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
assert result is True
mock_fallback.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
class TestEnsureLabelViaClient:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.GiteaClient")
def test_creates_label(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_label.return_value = {"id": 1}
mock_client_cls.return_value = mock_client
result = _ensure_label_via_client("bug", "ff0000", "A bug")
assert result is True
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.GiteaClient")
def test_label_already_exists(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_label.return_value = None
mock_client_cls.return_value = mock_client
result = _ensure_label_via_client("bug", "ff0000", "A bug")
assert result is False
class TestMain:
def test_main_missing_token(self) -> None:
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(click.ClickException) as exc:
main()
assert "REPO_TOKEN" in str(exc.value)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.TeaCLI")
@patch("scripts.configure_repo.GiteaClient")
def test_main_success(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [] # No existing labels
mock_tea_cls.return_value = mock_tea
main()
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_tea.create_label.assert_called_once()
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.TeaCLI")
@patch("scripts.configure_repo.GiteaClient")
def test_main_label_already_exists(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"name": "ready-to-merge"}]
mock_tea_cls.return_value = mock_tea
main()
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_tea.create_label.assert_not_called()
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.TeaCLI")
@patch("scripts.configure_repo.GiteaClient")
def test_main_tea_error_falls_back(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_label.return_value = {"id": 1}
mock_client_cls.return_value = mock_client
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
main()
mock_client.ensure_branch_protection.assert_called_once()
# Fallback to GiteaClient for label creation
mock_client.ensure_label.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.TeaCLI")
@patch("scripts.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
mock_client_cls.return_value = mock_client
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
with pytest.raises(click.ClickException) as exc:
main()
assert "HTTP" in str(exc.value)
def test_main_module_block() -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
with patch("scripts.configure_repo.TeaCLI") as mock_tea_cls:
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea_cls.return_value = mock_tea
import scripts.configure_repo as cr
with open(cr.__file__) as f:
source = f.read()
# Remove __main__ block so exec doesn't call main() before we inject the mock
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(cr.__dict__)
exec(compile(source, cr.__file__, "exec"), namespace)
namespace["GiteaClient"] = mock_client_cls
namespace["TeaCLI"] = mock_tea_cls
namespace["main"]()
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import scripts.ci.detect_release_commit as detect_release_commit
class TestGetCommitMessage:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert detect_release_commit.get_commit_message() == "feat: add feature"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(ClickException, match="git log failed"):
detect_release_commit.get_commit_message()
class TestIsReleaseCommit:
def test_release_commit(self) -> None:
assert detect_release_commit.is_release_commit("release: v1.0.0 [skip ci]") is True
def test_release_commit_no_skip(self) -> None:
assert detect_release_commit.is_release_commit("release: v0.1.0") is True
def test_regular_commit(self) -> None:
assert detect_release_commit.is_release_commit("feat: add feature") is False
def test_empty(self) -> None:
assert detect_release_commit.is_release_commit("") is False
class TestWriteGithubOutput:
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
detect_release_commit.write_github_output("is-release", "true")
with open(gh_file) as f:
assert f.read() == "is-release=true\n"
def test_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with pytest.raises(ClickException, match="GITHUB_OUTPUT"):
detect_release_commit.write_github_output("is-release", "true")
class TestMain:
def test_release_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(detect_release_commit, "get_commit_message", return_value="release: v1.0.0 [skip ci]"):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Release commit" in result.output
with open(gh_file) as f:
assert "is-release=true" in f.read()
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(detect_release_commit, "get_commit_message", return_value="feat: add feature"):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Regular merge commit" in result.output
with open(gh_file) as f:
assert "is-release=false" in f.read()
-210
View File
@@ -1,210 +0,0 @@
"""Unit tests for scripts/ci/discover_runners.py."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from scripts.ci.discover_runners import (
DEFAULT_MAX_RUNNERS,
generate_indices,
get_runner_count,
main,
query_runners,
)
class TestGenerateIndices:
def test_zero(self) -> None:
assert generate_indices(0) == []
def test_one(self) -> None:
assert generate_indices(1) == ["1"]
def test_three(self) -> None:
assert generate_indices(3) == ["1", "2", "3"]
def test_five(self) -> None:
assert generate_indices(5) == ["1", "2", "3", "4", "5"]
class TestQueryRunners:
@patch("scripts.ci.discover_runners.requests.get")
def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None:
"""Runners from repo, org, and admin levels are summed."""
responses = [
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}),
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}),
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 6
@patch("scripts.ci.discover_runners.requests.get")
def test_skips_non_200(self, mock_get: MagicMock) -> None:
"""Non-200 responses (e.g., 403 for admin) are skipped."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("scripts.ci.discover_runners.requests.get")
def test_handles_request_exception(self, mock_get: MagicMock) -> None:
"""Network errors are caught and don't crash."""
mock_get.side_effect = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(side_effect=__import__("requests").RequestException("network error")),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
]
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("scripts.ci.discover_runners.requests.get")
def test_all_failures_return_zero(self, mock_get: MagicMock) -> None:
"""When all API calls fail, returns 0."""
mock_get.side_effect = [
MagicMock(status_code=404),
MagicMock(status_code=404),
MagicMock(status_code=403),
]
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("scripts.ci.discover_runners.requests.get")
def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on repo level is caught."""
responses = [
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("scripts.ci.discover_runners.requests.get")
def test_value_error_on_org_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on org level is caught."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("scripts.ci.discover_runners.requests.get")
def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on admin level is caught."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("scripts.ci.discover_runners.requests.get")
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
"""Network errors on all levels return 0."""
mock_get.side_effect = __import__("requests").RequestException("network error")
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
class TestGetRunnerCount:
@patch("scripts.ci.discover_runners.query_runners", return_value=5)
def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 5
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"})
def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 4
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"})
def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"})
def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None:
"""MOLECULE_RUNNERS=0 is invalid, falls back to default."""
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None:
"""When no token, skips API and uses env/default."""
with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}):
result = get_runner_count("https://api.example.com", "", "owner", "repo")
assert result == 2
class TestMain:
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
def test_default_output(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "count=3" in result.output
assert 'indices=["1", "2", "3"]' in result.output
@patch("scripts.ci.discover_runners.get_runner_count", return_value=5)
def test_count_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "5"
@patch("scripts.ci.discover_runners.get_runner_count", return_value=4)
def test_indices_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1", "2", "3", "4"]
@patch("scripts.ci.discover_runners.get_runner_count", return_value=1)
def test_single_runner(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1"]
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "runner-count=3" in content
assert "runner-indices=" in content
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
-250
View File
@@ -1,250 +0,0 @@
"""Unit tests for scripts/ci/distribute_molecule.py."""
from pathlib import Path
from unittest.mock import patch
import click
import pytest
from click.testing import CliRunner
from scripts.ci.distribute_molecule import (
MOLECULE_ROOT,
PLATFORMS,
TestPair,
build_pairs,
cli,
discover_scenarios,
distribute,
pairs_for_runner,
)
class TestDiscoverScenarios:
def test_discovers_scenarios(self, tmp_path: Path) -> None:
root = tmp_path / "molecule"
(root / "default").mkdir(parents=True)
(root / "binary").mkdir(parents=True)
(root / "common").mkdir(parents=True)
(root / "_shared").mkdir(parents=True)
result = discover_scenarios(root)
assert result == ["binary", "default"]
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
with pytest.raises(click.ClickException) as exc:
discover_scenarios(tmp_path / "nonexistent")
assert "not found" in str(exc.value)
def test_default_root_constant(self) -> None:
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
class TestPairEncoding:
def test_encode_roundtrip(self) -> None:
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
encoded = pair.encode()
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
decoded = TestPair.decode(encoded)
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
assert decoded.platform["image"] == "ubuntu:22.04"
assert decoded.platform["command"] == ""
def test_encode_with_command(self) -> None:
pair = TestPair(
"default",
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
)
encoded = pair.encode()
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
decoded = TestPair.decode(encoded)
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
class TestBuildPairs:
def test_cross_product(self) -> None:
scenarios = ["a", "b"]
platforms = [
{"name": "p1", "image": "img1", "command": ""},
{"name": "p2", "image": "img2", "command": ""},
]
pairs = build_pairs(scenarios, platforms)
assert len(pairs) == 4
assert pairs[0].scenario == "a"
assert pairs[0].platform["name"] == "p1"
assert pairs[1].scenario == "a"
assert pairs[1].platform["name"] == "p2"
assert pairs[2].scenario == "b"
assert pairs[2].platform["name"] == "p1"
assert pairs[3].scenario == "b"
assert pairs[3].platform["name"] == "p2"
def test_default_platforms(self) -> None:
pairs = build_pairs(["default"])
assert len(pairs) == len(PLATFORMS)
assert all(p.scenario == "default" for p in pairs)
class TestDistribute:
def test_even_split(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute(pairs, 3)
assert len(groups) == 3
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 2
def test_uneven_split(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
groups = distribute(pairs, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_pairs(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
groups = distribute(pairs, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty_pairs(self) -> None:
groups = distribute([], 3)
assert groups == [[], [], []]
class TestPairsForRunner:
def test_returns_correct_subset(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
assert len(pairs_for_runner(pairs, 0, 3)) == 2
assert len(pairs_for_runner(pairs, 1, 3)) == 2
assert len(pairs_for_runner(pairs, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
pairs_for_runner(pairs, 5, 3)
assert "out of range" in str(exc.value)
def test_negative_index_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
pairs_for_runner(pairs, -1, 3)
assert "out of range" in str(exc.value)
class TestCli:
def test_list_flag(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
(root / "beta").mkdir(parents=True)
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--list"])
assert result.exit_code == 0
assert "alpha" in result.output
assert "beta" in result.output
def test_list_platforms_flag(self) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2204" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
for s in ["a", "b", "c"]:
(root / s).mkdir(parents=True)
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--max-runners", "3"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
assert "Runner 2:" in result.output
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
# 1-based index: "1" maps to internal 0
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
# Output should contain encoded pairs with platform info
assert "alpha|" in result.output
assert "ubuntu-2204" in result.output
class TestGithubEnv:
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=\n" in content
assert "SKIP=true" in content
def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code != 0
def test_main_module_block() -> None:
import scripts.ci.distribute_molecule as dm
with open(dm.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(dm.__dict__)
exec(compile(source, dm.__file__, "exec"), namespace)
assert callable(namespace["cli"])
-112
View File
@@ -1,112 +0,0 @@
"""Unit tests for scripts/ci/doc_coverage.py."""
from pathlib import Path
from click.testing import CliRunner
from scripts.ci.doc_coverage import (
check_command_documented,
check_module_documented,
extract_cli_commands,
main,
)
class TestExtractCliCommands:
def test_extracts_commands(self) -> None:
commands = extract_cli_commands()
# Should find all 9 CLI commands
assert "install" in commands
assert "update" in commands
assert "start" in commands
assert "stop" in commands
assert "enable" in commands
assert "disable" in commands
assert "status" in commands
assert "remove" in commands
assert "list" in commands
def test_returns_list(self) -> None:
commands = extract_cli_commands()
assert isinstance(commands, list)
assert len(commands) == 9
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
content = "## install\n\nInstall a runner."
assert check_command_documented("install", content) is True
def test_finds_command_in_code_block(self) -> None:
content = "```bash\ngrm install 192.168.1.10\n```"
assert check_command_documented("install", content) is True
def test_finds_command_with_grm_prefix(self) -> None:
content = "Use `grm start prod-runner` to start."
assert check_command_documented("start", content) is True
def test_missing_command(self) -> None:
content = "## Other stuff\n\nNo commands here."
assert check_command_documented("install", content) is False
class TestCheckModuleDocumented:
def test_finds_module(self) -> None:
content = "The cli.py module handles..."
assert check_module_documented("cli.py", content) is True
def test_missing_module(self) -> None:
content = "No modules mentioned."
assert check_module_documented("cli.py", content) is False
class TestMain:
def test_all_present(self, tmp_path: Path) -> None:
"""When all docs exist and cover all commands/modules, exit 0."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
# Write cli-commands.md with all commands
(docs / "user" / "cli-commands.md").write_text(
"## install\n## update\n## start\n## stop\n## enable\n## disable\n## status\n## remove\n## list\n"
)
# Write architecture.md with all modules
(docs / "tech" / "architecture.md").write_text(
"cli.py runner_manager.py executor.py registry.py i18n.py exceptions.py api_clients.py config.py"
)
# Write ci-cd-workflow.md with all scripts
(docs / "tech" / "ci-cd-workflow.md").write_text(
"auto_merge.py release.py publish.py review_pr.py "
"notify_failure.py post_merge.py classify_changes.py discover_runners.py "
"detect_release_commit.py push_badges.py "
"distribute_molecule.py molecule_ci_guard.py validate_commit_msg.py"
)
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "100%" in result.output
def test_missing_docs_fail(self, tmp_path: Path) -> None:
"""When docs are missing and --fail-on-missing is set, exit 1."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
assert result.exit_code == 1
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "MISSING" in result.output
+27 -7
View File
@@ -212,17 +212,14 @@ class TestAnsibleExecutorAdHoc:
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
with patch("sys.stdin.isatty", return_value=True):
result = executor.run_ad_hoc(
"10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True
)
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" in cmd
assert "--ask-become-pass" in cmd
def test_run_ad_hoc_ask_become_pass_no_tty(self, tmp_path: Path) -> None:
def test_run_ad_hoc_with_become_pass(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
@@ -230,14 +227,37 @@ class TestAnsibleExecutorAdHoc:
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
with patch("sys.stdin.isatty", return_value=False):
with patch("os.unlink"):
result = executor.run_ad_hoc(
"10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True
"10.0.0.1",
"ubuntu",
None,
"shell",
"cmd",
become=True,
ask_become_pass=True,
become_pass="secret",
)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" in cmd
assert "--become-password-file" in cmd
assert "--ask-become-pass" not in cmd
def test_run_ad_hoc_ask_become_pass_no_become(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
result_mock.returncode = 0
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=False, ask_become_pass=True)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" not in cmd
assert "--ask-become-pass" not in cmd
def test_run_ad_hoc_check_false(self, tmp_path: Path) -> None:
-290
View File
@@ -1,290 +0,0 @@
"""Unit tests for scripts/generate_badges.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from scripts.generate_badges import (
COLOR_HEX,
cli,
coverage_color,
doc_coverage_color,
extract_coverage,
extract_doc_coverage,
extract_test_count,
generate_badges,
make_badge,
read_version,
render_svg,
run_command,
)
class TestRunCommand:
@patch("scripts.generate_badges.subprocess.run")
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
rc, out, err = run_command(["echo", "hello"])
assert rc == 0
assert out == "out"
assert err == "err"
mock_run.assert_called_once()
class TestMakeBadge:
def test_creates_valid_badge_dict(self) -> None:
badge = make_badge("coverage", "100%", "brightgreen")
assert badge == {
"schemaVersion": 1,
"label": "coverage",
"message": "100%",
"color": "brightgreen",
}
class TestRenderSvg:
def test_generates_valid_svg(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert svg.startswith("<svg")
assert svg.endswith("</svg>\n")
assert "coverage" in svg
assert "100%" in svg
assert COLOR_HEX["brightgreen"] in svg
def test_uses_color_hex_for_known_colors(self) -> None:
svg = render_svg("tests", "573 passing", "brightgreen")
assert "#4c1" in svg
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
svg = render_svg("label", "msg", "#abc123")
assert "#abc123" in svg
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
svg = render_svg("label", "msg", "nonexistent")
assert "#9f9f9f" in svg
def test_escapes_xml_special_chars(self) -> None:
svg = render_svg("label", "<script>", "red")
assert "<script>" not in svg
assert "&lt;script&gt;" in svg
def test_has_correct_dimensions(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert 'width="' in svg
assert 'height="20"' in svg
class TestExtractCoverage:
def test_extracts_from_total_line(self) -> None:
output = (
"src/gitea_runner_manager/cli.py 118 0 100%\n"
"TOTAL 1798 0 100.00%\n"
)
assert extract_coverage(output) == 100.0
def test_extracts_partial_coverage(self) -> None:
output = "TOTAL 100 20 80.00%\n"
assert extract_coverage(output) == 80.0
def test_extracts_without_decimal(self) -> None:
output = "TOTAL 1900 0 100%\n"
assert extract_coverage(output) == 100.0
def test_returns_none_when_no_match(self) -> None:
assert extract_coverage("no coverage here") is None
class TestExtractTestCount:
def test_extracts_passed_count(self) -> None:
assert extract_test_count("543 passed in 2.32s") == 543
def test_extracts_with_warnings(self) -> None:
assert extract_test_count("543 passed, 1 warning in 2.32s") == 543
def test_returns_none_when_no_match(self) -> None:
assert extract_test_count("no tests here") is None
class TestExtractDocCoverage:
def test_extracts_percentage(self) -> None:
output = "\nDoc coverage: 20/20 (100%)"
assert extract_doc_coverage(output) == 100
def test_extracts_partial(self) -> None:
output = "\nDoc coverage: 18/20 (90%)"
assert extract_doc_coverage(output) == 90
def test_returns_none_when_no_match(self) -> None:
assert extract_doc_coverage("no doc coverage here") is None
class TestCoverageColor:
def test_100_is_brightgreen(self) -> None:
assert coverage_color(100.0) == "brightgreen"
def test_90_is_green(self) -> None:
assert coverage_color(90.0) == "green"
def test_80_is_yellowgreen(self) -> None:
assert coverage_color(80.0) == "yellowgreen"
def test_70_is_yellow(self) -> None:
assert coverage_color(70.0) == "yellow"
def test_60_is_orange(self) -> None:
assert coverage_color(60.0) == "orange"
def test_below_60_is_red(self) -> None:
assert coverage_color(50.0) == "red"
class TestDocCoverageColor:
def test_100_is_brightgreen(self) -> None:
assert doc_coverage_color(100) == "brightgreen"
def test_90_is_green(self) -> None:
assert doc_coverage_color(90) == "green"
def test_80_is_yellowgreen(self) -> None:
assert doc_coverage_color(80) == "yellowgreen"
def test_70_is_yellow(self) -> None:
assert doc_coverage_color(70) == "yellow"
def test_below_70_is_orange(self) -> None:
assert doc_coverage_color(60) == "orange"
class TestReadVersion:
def test_reads_version_from_init(self) -> None:
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
mock_file.read_text.return_value = '__version__ = "0.5.0"\n'
assert read_version() == "0.5.0"
def test_returns_unknown_when_no_version(self) -> None:
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
mock_file.read_text.return_value = "no version here\n"
assert read_version() == "unknown"
class TestGenerateBadges:
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_generates_all_badge_files(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected
# Verify SVG files were written
for name in expected:
svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists()
content = svg_file.read_text()
assert content.startswith("<svg")
assert "</svg>" in content
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "A"
assert badges["quality"]["color"] == "brightgreen"
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
(1, "error", ""),
(0, "output", ""),
(0, "output", ""),
(0, "output", ""),
]
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "F"
assert badges["quality"]["color"] == "red"
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=None)
@patch("scripts.generate_badges.extract_test_count", return_value=None)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=None)
def test_badges_show_unknown_when_extraction_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (1, "garbled output", "")
badges = generate_badges(tmp_path)
assert badges["coverage"]["message"] == "unknown"
assert badges["coverage"]["color"] == "red"
assert badges["tests"]["message"] == "unknown"
assert badges["tests"]["color"] == "red"
assert badges["docs"]["message"] == "unknown"
assert badges["docs"]["color"] == "red"
class TestCli:
@patch("scripts.generate_badges.generate_badges")
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"),
}
runner = CliRunner()
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])
assert result.exit_code == 0
assert "Generating badges" in result.output
assert "Generated 2 badges" in result.output
mock_gen.assert_called_once_with(tmp_path)
def test_main_module_block() -> None:
import scripts.generate_badges as gb
with patch.object(gb, "cli") as mock_cli:
with patch.object(gb, "__name__", "__main__"):
gb.cli([])
mock_cli.assert_called_once_with([])
-355
View File
@@ -1,355 +0,0 @@
"""Unit tests for scripts/gitea_cli.py."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from scripts.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
class TestExtractIssueNumber:
def test_extract_from_created_issue(self) -> None:
assert _extract_issue_number("Created issue #42: Bug title") == 42
def test_extract_no_hash(self) -> None:
assert _extract_issue_number("No issue number here") == 0
def test_extract_multiple_hashes(self) -> None:
assert _extract_issue_number("Issue #5 and PR #10") == 5
def test_extract_with_colon(self) -> None:
assert _extract_issue_number("Created issue #7: title") == 7
def test_extract_invalid_number(self) -> None:
assert _extract_issue_number("Issue #abc: title") == 0
class TestExtractPrNumber:
def test_extract_from_created_pr(self) -> None:
assert _extract_pr_number("Created PR #128: Feature") == 128
def test_extract_no_number(self) -> None:
assert _extract_pr_number("No PR number") == 0
class TestTeaCLIInit:
def test_auto_detect_tea(self) -> None:
with patch("shutil.which", return_value="/usr/bin/tea"):
cli = TeaCLI()
assert cli._tea == "/usr/bin/tea"
def test_explicit_tea_bin(self) -> None:
cli = TeaCLI(tea_bin="/custom/tea")
assert cli._tea == "/custom/tea"
def test_fallback_to_tea(self) -> None:
with patch("shutil.which", return_value=None):
cli = TeaCLI()
assert cli._tea == "tea"
def test_with_repo(self) -> None:
cli = TeaCLI(repo="owner/repo")
assert cli._repo == "owner/repo"
class TestTeaCLIRun:
def test_run_success_json(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
with patch("subprocess.run", return_value=mock_result):
output = cli._run(["labels", "list"])
assert output == '[{"id": 1}]'
def test_run_success_raw(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
with patch("subprocess.run", return_value=mock_result):
output = cli._run_raw(["issues", "create"])
assert output == "Created issue #42"
def test_run_failure_raises(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_includes_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli._run(["labels", "list"])
cmd = mock_run.call_args[0][0]
assert "--output" in cmd
assert "json" in cmd
def test_run_raw_no_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli._run_raw(["whoami"])
cmd = mock_run.call_args[0][0]
assert "--output" not in cmd
class TestRepoArg:
def test_with_repo_arg(self) -> None:
cli = TeaCLI(repo="owner/repo")
assert cli._repo_arg() == ["--repo", "owner/repo"]
def test_with_explicit_repo(self) -> None:
cli = TeaCLI()
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
def test_without_repo(self) -> None:
cli = TeaCLI()
assert cli._repo_arg() == []
def test_explicit_overrides_default(self) -> None:
cli = TeaCLI(repo="default/repo")
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
class TestCreateIssue:
def test_create_issue_basic(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
with patch("subprocess.run", return_value=mock_result):
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
assert issue["index"] == 42
assert issue["title"] == "Bug"
def test_create_issue_with_labels(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
with patch("subprocess.run", return_value=mock_result):
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
assert issue["index"] == 5
class TestListLabels:
def test_list_labels_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
labels = cli.list_labels("owner/repo")
assert len(labels) == 2
assert labels[0]["name"] == "bug"
def test_list_labels_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
labels = cli.list_labels("owner/repo")
assert labels == []
class TestCreateLabel:
def test_create_label_full(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
with patch("subprocess.run", return_value=mock_result):
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
assert label["name"] == "bug"
assert label["color"] == "ff0000"
def test_create_label_name_only(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
with patch("subprocess.run", return_value=mock_result):
label = cli.create_label("owner/repo", name="wip")
assert label["name"] == "wip"
assert label["color"] == ""
class TestAddLabel:
def test_add_label_single(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.add_label("owner/repo", 42, ["ready-to-merge"])
cmd = mock_run.call_args[0][0]
assert "--add-labels" in cmd
assert "ready-to-merge" in cmd
assert "42" in cmd
def test_add_label_multiple(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.add_label("owner/repo", 42, ["bug", "urgent"])
cmd = mock_run.call_args[0][0]
assert "--add-labels" in cmd
def test_add_label_empty_list(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
with patch("subprocess.run") as mock_run:
cli.add_label("owner/repo", 42, [])
mock_run.assert_not_called()
class TestCreatePR:
def test_create_pr_basic(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
with patch("subprocess.run", return_value=mock_result):
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
assert pr["index"] == 128
def test_create_pr_with_body(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
cmd = mock_run.call_args[0][0]
assert "--body" in cmd
assert "Description" in cmd
class TestMergePR:
def test_merge_pr_squash(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.merge_pr("owner/repo", 42, style="squash")
cmd = mock_run.call_args[0][0]
assert "--style" in cmd
assert "squash" in cmd
assert "42" in cmd
def test_merge_pr_default_style(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.merge_pr("owner/repo", 42)
cmd = mock_run.call_args[0][0]
assert "squash" in cmd
class TestReviewPR:
def test_review_approve(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
cmd = mock_run.call_args[0][0]
assert "--approve" in cmd
assert "--comment" in cmd
def test_review_reject(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
cmd = mock_run.call_args[0][0]
assert "--reject" in cmd
def test_review_comment(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
cmd = mock_run.call_args[0][0]
assert "--approve" not in cmd
assert "--reject" not in cmd
assert "--comment" in cmd
def test_review_no_body(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="COMMENT")
cmd = mock_run.call_args[0][0]
assert "--comment" not in cmd
class TestCreateRelease:
def test_create_release_full(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
release = cli.create_release(
"owner/repo",
tag="v1.0.0",
title="Release 1.0.0",
body="Notes",
target="master",
)
cmd = mock_run.call_args[0][0]
assert "v1.0.0" in cmd
assert "--title" in cmd
assert "--note" in cmd
assert "--target" in cmd
assert release["tag"] == "v1.0.0"
def test_create_release_draft(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
cmd = mock_run.call_args[0][0]
assert "--draft" in cmd
def test_create_release_prerelease(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
cmd = mock_run.call_args[0][0]
assert "--prerelease" in cmd
def test_create_release_minimal(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
release = cli.create_release("owner/repo", tag="v1.0.0")
cmd = mock_run.call_args[0][0]
assert "--title" not in cmd
assert "--note" not in cmd
assert release["tag"] == "v1.0.0"
class TestListReleases:
def test_list_releases_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
releases = cli.list_releases("owner/repo")
assert len(releases) == 2
def test_list_releases_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
releases = cli.list_releases("owner/repo")
assert releases == []
class TestListBranches:
def test_list_branches_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
branches = cli.list_branches("owner/repo")
assert len(branches) == 2
def test_list_branches_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
branches = cli.list_branches("owner/repo")
assert branches == []
class TestWhoami:
def test_whoami(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "emil"
-94
View File
@@ -1,94 +0,0 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
import scripts.install_checkmake as install_checkmake
class TestArch:
def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"):
assert install_checkmake._arch() == "amd64"
def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"):
assert install_checkmake._arch() == "arm64"
def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException):
install_checkmake._arch()
class TestInstallWithGo:
def test_no_go(self) -> None:
with patch("shutil.which", return_value=None):
assert install_checkmake._install_with_go() is False
def test_with_go(self) -> None:
with patch("shutil.which", return_value="/usr/bin/go"):
with patch("subprocess.run") as mock_run:
assert install_checkmake._install_with_go() is True
mock_run.assert_called_once_with(
[
"/usr/bin/go",
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return path, None
with patch.object(install_checkmake, "TARGET_PATH", target):
with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake._download_binary()
mock_retrieve.assert_called_once()
assert target.exists()
assert target.stat().st_mode & 0o111
class TestMain:
def test_already_installed(self) -> None:
with patch("shutil.which", return_value="/usr/bin/checkmake"):
install_checkmake.main()
def test_install_with_go(self) -> None:
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
with patch("subprocess.run") as mock_run:
install_checkmake.main()
mock_run.assert_called_once_with(
[
"/usr/bin/go",
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
def test_download_when_no_go(self, tmp_path: Path) -> None:
target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return path, None
with patch.object(install_checkmake, "TARGET_PATH", target):
with patch("shutil.which", side_effect=[None, None]):
with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake.main()
mock_retrieve.assert_called_once()
-312
View File
@@ -1,312 +0,0 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import scripts.install_tools as install_tools
class TestArch:
def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"):
assert install_tools._arch() == "amd64"
def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"):
assert install_tools._arch() == "arm64"
def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException):
install_tools._arch()
class TestIsInstalled:
def test_on_path(self) -> None:
with patch("shutil.which", return_value="/usr/bin/actionlint"):
assert install_tools._is_installed("actionlint") is True
def test_in_target_dir(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
(tmp_path / "actionlint").touch()
with patch("shutil.which", return_value=None):
assert install_tools._is_installed("actionlint") is True
def test_not_installed(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch("shutil.which", return_value=None):
assert install_tools._is_installed("actionlint") is False
class TestDownload:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "file.bin"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"data")
return str(path), None
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_tools._download("https://example.com/file", dest)
mock_retrieve.assert_called_once()
assert dest.read_bytes() == b"data"
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "act_runner"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(install_tools, "_download", side_effect=_write_file):
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
assert result == dest
assert dest.exists()
assert dest.stat().st_mode & 0o111
class TestDownloadAndExtractTarball:
def test_extract(self, tmp_path: Path) -> None:
import tarfile
# Create a fake tarball with a binary
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake binary"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
result = install_tools._download_and_extract_tarball(
"https://example.com/actionlint.tar.gz", "actionlint"
)
assert result == target_dir / "actionlint"
assert result.exists()
assert result.read_bytes() == binary_content
def test_binary_not_found(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="other_file")
info.size = 0
tar.addfile(info, io.BytesIO(b""))
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
with pytest.raises(ClickException, match="not found in archive"):
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
class TestInstallActionlint:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_actionlint() is True
def test_install(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake actionlint"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_actionlint() is True
assert (tmp_path / "actionlint").exists()
class TestInstallGitCliff:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_git_cliff() is True
def test_install(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake git-cliff"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="git-cliff")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_git_cliff() is True
assert (tmp_path / "git-cliff").exists()
class TestInstallActRunner:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_act_runner() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_act_runner() is True
assert (tmp_path / "act_runner").exists()
class TestInstallTea:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_tea() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_tea() is True
assert (tmp_path / "tea").exists()
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch("shutil.which", return_value=None):
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
install_tools.list_tools()
class TestInstallTool:
def test_actionlint(self) -> None:
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
assert install_tools._install_tool("actionlint") is True
mock.assert_called_once()
def test_git_cliff(self) -> None:
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
assert install_tools._install_tool("git-cliff") is True
mock.assert_called_once()
def test_act_runner(self) -> None:
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
assert install_tools._install_tool("act_runner") is True
mock.assert_called_once()
def test_tea(self) -> None:
with patch.object(install_tools, "install_tea", return_value=True) as mock:
assert install_tools._install_tool("tea") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
class TestMain:
def test_list_status(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_is_installed", return_value=True):
result = runner.invoke(install_tools.main, ["--list"])
assert result.exit_code == 0
assert "actionlint" in result.output
def test_install_all(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 4
def test_install_specific_tool(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
assert result.exit_code == 0
mock_install.assert_called_once_with("actionlint")
def test_install_multiple_specific_tools(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
assert result.exit_code == 0
assert mock_install.call_count == 2
def test_install_failure(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
assert result.exit_code != 0
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When TARGET_DIR is not in PATH, a reminder is printed."""
monkeypatch.setenv("PATH", "/usr/bin:/bin")
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True):
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert "Add" in result.output
assert "PATH" in result.output
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When TARGET_DIR is in PATH, no reminder is printed."""
target_dir = str(install_tools.TARGET_DIR)
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True):
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert "Add" not in result.output
-130
View File
@@ -1,130 +0,0 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
import scripts.molecule_all as molecule_all
class TestRunMolecule:
def test_success(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
assert rc == 0
mock_run.assert_called_once()
def test_failure(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
assert rc == 1
def test_non_default_scenario_adds_flag(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
cmd = mock_run.call_args[0][0]
assert "-s" in cmd
assert "lifecycle" in cmd
class TestRunPlatform:
def test_all_scenarios_pass(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
with patch("scripts.molecule_all._run_molecule", return_value=0) as mock_run:
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
assert rc == 0
assert mock_run.call_count == 2
def test_stops_on_failure(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
with patch("scripts.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
assert rc == 1
assert mock_run.call_count == 1
def test_sets_env_vars(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
captured_env: dict[str, str] = {}
def _capture_env(cmd, cwd, env):
captured_env.update(env)
import subprocess
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch("subprocess.run", side_effect=_capture_env):
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
def test_empty_command_removes_env(self) -> None:
platform = {"name": "custom", "image": "custom:latest", "command": ""}
captured_env: dict[str, str] = {}
def _capture_env(cmd, cwd, env):
captured_env.update(env)
import subprocess
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch("subprocess.run", side_effect=_capture_env):
molecule_all._run_platform(
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
)
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
class TestMain:
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
assert result.exit_code != 0
assert "molecule not found" in result.output
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
runner = CliRunner()
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code != 0
assert "Role directory not found" in result.output
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
runner = CliRunner()
with patch("scripts.molecule_all._run_platform", return_value=0):
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
assert "All molecule scenarios passed" in result.output
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
runner = CliRunner()
with patch("scripts.molecule_all._run_platform", return_value=1):
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code != 0
-428
View File
@@ -1,428 +0,0 @@
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch
import pytest
import requests
from scripts.ci.molecule_ci_guard import (
any_other_runner_failed,
build_env_for_pair,
build_molecule_cmd,
cli,
get_running_jobs,
poll_for_other_failures,
)
class TestGetRunningJobs:
def test_returns_jobs(self) -> None:
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {
"jobs": [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
assert len(jobs) == 2
mock_get.assert_called_once()
def test_raises_on_request_error(self) -> None:
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
mock_get.side_effect = requests.RequestException("boom")
with pytest.raises(requests.RequestException):
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
class TestAnyOtherRunnerFailed:
def test_detects_other_failure(self) -> None:
jobs = [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
{"name": "molecule-tests (2)", "conclusion": "running"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
def test_ignores_current_runner(self) -> None:
jobs = [
{"name": "molecule-tests (0)", "conclusion": "failure"},
{"name": "molecule-tests (1)", "conclusion": "success"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
def test_ignores_non_molecule_jobs(self) -> None:
jobs = [
{"name": "quality", "conclusion": "failure"},
{"name": "molecule-tests (1)", "conclusion": "success"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
class TestBuildMoleculeCmd:
def test_default_scenario(self) -> None:
assert build_molecule_cmd("default") == ["molecule", "test"]
def test_named_scenario(self) -> None:
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
class TestBuildEnvForPair:
def test_with_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
def test_without_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_without_command_removes_existing(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None:
stop_event = MagicMock()
failed_event = MagicMock()
def side_effect(*args, **kwargs):
if stop_event.wait.call_count < 1:
return [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
return []
with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
mock_get_jobs.side_effect = side_effect
stop_event.is_set.side_effect = [False, False]
stop_event.wait.return_value = True
poll_for_other_failures(
"https://gitea.example",
"owner",
"repo",
"token",
123,
"molecule-tests",
0,
stop_event,
failed_event,
)
failed_event.set.assert_called_once()
def test_poll_warns_on_api_error(self) -> None:
stop_event = MagicMock()
failed_event = MagicMock()
with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
mock_get_jobs.side_effect = requests.RequestException("boom")
stop_event.is_set.side_effect = [False, True]
stop_event.wait.return_value = True
poll_for_other_failures(
"https://gitea.example",
"owner",
"repo",
"token",
123,
"molecule-tests",
0,
stop_event,
failed_event,
)
failed_event.set.assert_not_called()
class TestCli:
def test_all_pass(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "FAILED" in result.output
def test_exits_before_starting_when_already_failed(self) -> None:
from click.testing import CliRunner
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep"),
):
mock_get_jobs.return_value = [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "Another molecule runner failed" in result.output
def test_keyboard_interrupt_kills_process(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep", side_effect=KeyboardInterrupt),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_default_owner_repo_fallback(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "invalid",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
proc = MagicMock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
def test_main_module_block() -> None:
import scripts.ci.molecule_ci_guard as mg
with open(mg.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(mg.__dict__)
exec(compile(source, mg.__file__, "exec"), namespace)
assert callable(namespace["cli"])
-168
View File
@@ -1,168 +0,0 @@
"""Unit tests for scripts/ci/notify_failure.py."""
import http
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.ci.notify_failure import main
from scripts.gitea_cli import TeaCLIError
class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("scripts.gitea_cli.TeaCLI")
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
[
"--repo",
"owner/repo",
"--run-id",
"123",
"--workflow",
"release",
"--commit",
"abc123def456",
],
)
assert result.exit_code == 0
assert "issue #42" in result.output
mock_tea.create_issue.assert_called_once()
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("scripts.gitea_cli.TeaCLI")
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
)
assert result.exit_code == 0
assert "issue #43" in result.output
mock_tea.add_label.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("scripts.gitea_cli.TeaCLI")
def test_tea_error_falls_back_to_client(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""When tea fails, fall back to GiteaClient."""
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea.create_issue.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
with patch("scripts.ci.notify_failure.GiteaClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_client.create_issue.return_value = {"id": 50}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #50" in result.output
mock_client.create_issue.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value=None)
@patch("scripts.ci.notify_failure.GiteaClient")
def test_tea_not_installed_uses_client(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None:
"""When tea is not installed, use GiteaClient directly."""
mock_client = MagicMock()
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_client.create_issue.return_value = {"id": 51}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "126", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #51" in result.output
mock_client.create_issue.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value=None)
@patch("scripts.ci.notify_failure.GiteaClient")
def test_client_api_error_raises(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_labels.return_value = []
mock_client.create_issue.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "forbidden")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "127", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 1
assert "403" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("scripts.gitea_cli.TeaCLI")
def test_tea_list_labels_error_continues_without_labels(
self, mock_tea_cls: MagicMock, mock_which: MagicMock
) -> None:
"""If listing labels fails via tea, issue is still created without labels."""
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #50" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("scripts.gitea_cli.TeaCLI")
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""If adding label fails via tea, issue is still reported as created."""
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #51" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
-25
View File
@@ -1,25 +0,0 @@
"""Unit tests for scripts/ci/platforms.py."""
from scripts.ci.platforms import PLATFORMS
class TestPlatforms:
def test_platforms_not_empty(self) -> None:
assert len(PLATFORMS) >= 4
def test_each_platform_has_required_keys(self) -> None:
for p in PLATFORMS:
assert "name" in p
assert "image" in p
assert "command" in p
def test_platform_names_unique(self) -> None:
names = [p["name"] for p in PLATFORMS]
assert len(names) == len(set(names))
def test_known_platforms_present(self) -> None:
names = {p["name"] for p in PLATFORMS}
assert "ubuntu-2204" in names
assert "ubuntu-2404" in names
assert "debian-12" in names
assert "archlinux" in names
-328
View File
@@ -1,328 +0,0 @@
"""Unit tests for scripts/ci/post_merge.py."""
import http
import subprocess
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.ci.post_merge import (
_get_git_commit_message,
_get_git_commit_sha,
build_comment,
extract_conventional_msg,
extract_task_id,
main,
resolve_task_id,
)
class TestExtractTaskId:
def test_extracts_from_first_line(self) -> None:
assert extract_task_id("GRM-19: fix: resolve bug\n\nBody") == "GRM-19"
def test_returns_empty_when_missing(self) -> None:
assert extract_task_id("fix: resolve bug") == ""
class TestExtractConventionalMsg:
def test_strips_colon_prefix(self) -> None:
"""Legacy format: GRM-N: <message>"""
assert extract_conventional_msg("GRM-19: fix: resolve bug") == "fix: resolve bug"
def test_strips_space_prefix(self) -> None:
"""Current format: GRM-N <message>"""
assert extract_conventional_msg("GRM-19 fix: resolve bug") == "fix: resolve bug"
def test_returns_unchanged_without_prefix(self) -> None:
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
class TestBuildComment:
def test_html_format(self) -> None:
html = build_comment("GRM-19", "fix: bug", "abc123")
assert "<strong>GRM-19</strong>" in html
assert "fix: bug" in html
assert "<code>abc123</code>" in html
class TestResolveTaskId:
def test_found(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 42, "identifier": "GRM-19"},
]
assert resolve_task_id(mock_client, "GRM-19") == 42
mock_client.list_project_tasks.assert_called_once()
def test_not_found_raises(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
with pytest.raises(click.ClickException) as exc:
resolve_task_id(mock_client, "GRM-99")
assert "Could not find" in str(exc.value)
def test_found_on_second_page(self) -> None:
"""Task is on page 2 when project has more than 50 tasks."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(50)]
page2 = [{"id": 100, "identifier": "GRM-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
assert resolve_task_id(mock_client, "GRM-99") == 100
assert mock_client.list_project_tasks.call_count == 2
def test_stops_when_page_is_partial(self) -> None:
"""Stops paginating when a page has fewer than DEFAULT_PER_PAGE results."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(10)]
mock_client.list_project_tasks.return_value = page1
with pytest.raises(click.ClickException) as exc:
resolve_task_id(mock_client, "GRM-99")
assert "Could not find" in str(exc.value)
assert mock_client.list_project_tasks.call_count == 1
def test_http_error_propagates(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
with pytest.raises(APIError):
resolve_task_id(mock_client, "GRM-19")
class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_full_flow(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
)
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_client.post_comment.assert_called_once()
mock_client.update_task.assert_called_once_with(267, done=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: resolve bug"])
assert result.exit_code == 0
mock_client.post_comment.assert_called_once()
args, _ = mock_client.post_comment.call_args
assert "unknown" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_non_release_warns(self) -> None:
"""Non-release commits without GRM-N prefix should warn, not fail."""
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0
assert "No task ID" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None:
"""Release commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["release: v0.3.2"])
assert result.exit_code == 0
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_revert_commit_skips(self) -> None:
"""Revert commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
assert result.exit_code == 0
assert "Infrastructure commit" in result.output
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_merge_commit_skips(self) -> None:
"""Merge commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["Merge pull request #42"])
assert result.exit_code == 0
assert "Infrastructure commit" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "Could not find" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client.post_comment.return_value = None
mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
class TestGetGitCommitMessage:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert _get_git_commit_message() == "GRM-20: fix: bug"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(click.ClickException, match="git log failed"):
_get_git_commit_message()
class TestGetGitCommitSha:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert _get_git_commit_sha() == "abc123"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(click.ClickException, match="git rev-parse failed"):
_get_git_commit_sha()
class TestFromGit:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--from-git"])
assert result.exit_code == 0
assert "updated and marked done" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
def test_from_git_with_explicit_sha(
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
assert result.exit_code == 0
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_msg_and_no_from_git(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "commit_msg" in result.output
class TestGitSha:
"""Tests for the --git-sha option (race condition fix)."""
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
"""--git-sha reads commit message from a specific SHA, not HEAD."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "abc123"])
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_client.post_comment.assert_called_once()
# Verify the SHA was passed to the comment
args, _ = mock_client.post_comment.call_args
assert "abc123" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_git_sha_failure_raises(self) -> None:
"""--git-sha with invalid SHA should raise."""
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "badsha"])
assert result.exit_code != 0
assert "git log failed" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
assert result.exit_code == 0
args, _ = mock_client.post_comment.call_args
assert "explicit_sha" in args[1]
-553
View File
@@ -1,553 +0,0 @@
"""Unit tests for scripts/ci/pr_review.py."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.ci.pr_review import (
ReviewResult,
build_review_body,
check_architecture_compliance,
check_best_practices,
check_commit_conventions,
check_documentation,
check_function_length,
check_security,
check_test_coverage,
is_python_file,
is_workflow_only,
main,
post_review,
run_review,
)
class TestIsPythonFile:
def test_python_file_in_src(self) -> None:
assert is_python_file("src/gitea_runner_manager/cli.py") is True
def test_python_file_in_scripts(self) -> None:
assert is_python_file("scripts/ci/release.py") is True
def test_test_file_excluded(self) -> None:
assert is_python_file("tests/unit/test_cli.py") is False
def test_non_python_file(self) -> None:
assert is_python_file("README.md") is False
def test_yaml_file(self) -> None:
assert is_python_file(".gitea/workflows/ci.yml") is False
class TestIsWorkflowOnly:
def test_yaml_is_workflow(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
def test_md_is_workflow(self) -> None:
assert is_workflow_only("README.md") is True
def test_python_is_not_workflow(self) -> None:
assert is_workflow_only("src/gitea_runner_manager/cli.py") is False
def test_ansible_is_workflow(self) -> None:
assert is_workflow_only("ansible/tasks/main.yml") is True
class TestReviewResult:
def test_empty_result_has_no_issues(self) -> None:
result = ReviewResult()
assert result.has_issues is False
def test_add_issue_makes_has_issues_true(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
assert result.has_issues is True
assert len(result.issues) == 1
assert result.issues[0]["path"] == "src/foo.py"
assert result.issues[0]["new_position"] == 10
def test_add_summary(self) -> None:
result = ReviewResult()
result.add_summary("all good")
assert "all good" in result.summary
class TestCheckArchitectureCompliance:
def test_subprocess_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "subprocess" in result.issues[0]["body"].lower()
def test_subprocess_in_other_file_ok(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_no_changes_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert any("Architecture compliance: OK" in s for s in result.summary)
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_os_system_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
def test_bare_except_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/runner_manager.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "bare except" in result.issues[0]["body"]
def test_todo_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "TODO" in result.issues[0]["body"]
def test_clean_code_no_issues(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
}
]
check_best_practices(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_best_practices(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
check_best_practices(files, result)
assert not result.has_issues
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/config.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
def test_example_token_not_flagged(self) -> None:
result = ReviewResult()
files = [
{
"filename": ".env.example",
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
}
]
check_security(files, result)
assert not result.has_issues
def test_shell_true_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "shell=True" in result.issues[0]["body"]
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/config.py", "patch": ""}]
check_security(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
check_security(files, result)
assert not result.has_issues
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
result = ReviewResult()
# Create a patch with a function that adds > 50 lines
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_short_function_no_warning(self) -> None:
result = ReviewResult()
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_function_length(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "README.md", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_multiple_functions_resets_count(self) -> None:
"""Two short functions back-to-back should not trigger the length warning."""
result = ReviewResult()
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_long_function_followed_by_new_hunk(self) -> None:
"""Long function followed by @@ header triggers the warning at hunk boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = (
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
)
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_long_function_followed_by_new_def(self) -> None:
"""Long function followed by another def triggers the warning at def boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_docs_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
def test_ansible_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_only_doc_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
class TestCheckTestCoverage:
def test_src_changes_without_tests_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
check_test_coverage(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_tests_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
def test_only_test_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
class TestBuildReviewBody:
def test_body_contains_summary(self) -> None:
result = ReviewResult()
result.add_summary("- Architecture compliance: OK")
body = build_review_body(result)
assert "Architecture compliance: OK" in body
assert "Automated PR Review" in body
def test_body_contains_issues(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
body = build_review_body(result)
assert "1 issue(s) found" in body
assert "src/foo.py:10" in body
assert "bad code" in body
def test_body_contains_no_issues_message(self) -> None:
result = ReviewResult()
body = build_review_body(result)
assert "No issues found" in body
def test_body_contains_checklist_reference(self) -> None:
"""Review body must reference REVIEW_CHECKLIST.md for manual review."""
result = ReviewResult()
body = build_review_body(result)
assert "REVIEW_CHECKLIST.md" in body
assert "--checklist-confirmed" in body
class TestRunReview:
@patch("scripts.ci.pr_review.GiteaClient")
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = []
result = run_review(mock_client, "42")
assert "No files changed" in result.summary[0]
@patch("scripts.ci.pr_review.GiteaClient")
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
result = run_review(mock_client, "42")
assert result.has_issues
def test_run_review_handles_api_error(self) -> None:
client = MagicMock()
client.get_pr_files.side_effect = APIError(404, "Not found")
result = run_review(client, "42")
assert any("ERROR" in s for s in result.summary)
class TestCheckCommitConventions:
def test_conventional_commit_found(self) -> None:
"""Should report OK when at least one commit is conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_no_conventional_commit(self) -> None:
"""Should warn when no commits are conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "updated stuff"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("WARNING" in s for s in result.summary)
def test_merge_commits_excluded(self) -> None:
"""Merge commits should be excluded from the check."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "fix: resolve bug"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_all_merges_and_reverts(self) -> None:
"""Should report OK when all commits are merges/reverts."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "Revert: bad commit"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("merges/reverts" in s for s in result.summary)
def test_no_commits(self) -> None:
"""Should report OK when there are no commits."""
client = MagicMock()
client.get_pr_commits.return_value = []
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("no commits" in s for s in result.summary)
def test_api_error(self) -> None:
"""Should report ERROR when API call fails."""
client = MagicMock()
client.get_pr_commits.side_effect = APIError(500, "server error")
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("ERROR" in s for s in result.summary)
class TestPostReview:
def test_post_review_with_issues(self) -> None:
client = MagicMock()
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "REQUEST_CHANGES"
assert call_args[1]["comments"] == result.issues
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
client = MagicMock()
result = ReviewResult()
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "COMMENT"
assert call_args[1]["comments"] == []
class TestMain:
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once()
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 124},
]
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #124" in result.output
assert client.create_review.call_count == 2
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""Non-approval API errors should re-raise, not fall back."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code != 0
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
def test_main_module_block() -> None:
import scripts.ci.pr_review as pr
with patch.object(pr, "main") as mock_main:
with patch.object(pr, "__name__", "__main__"):
pr.main([])
mock_main.assert_called_once_with([])
-190
View File
@@ -1,190 +0,0 @@
"""Unit tests for scripts/ci/publish.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from scripts.ci.publish import (
build_package,
generate_release_notes,
main,
publish_to_pypi,
)
from scripts.gitea_cli import TeaCLIError
class TestGenerateReleaseNotes:
@patch("scripts.ci.publish.subprocess.run")
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x")
result = generate_release_notes("v1.0.0")
assert "## v1.0.0" in result
assert "feat: x" in result
@patch("scripts.ci.publish.subprocess.run")
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n")
result = generate_release_notes("v1.0.0")
assert result == "changelog"
@patch("scripts.ci.publish.subprocess.run")
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="")
result = generate_release_notes("v1.0.0")
assert "Release v1.0.0" in result
assert "CHANGELOG.md" in result
@patch("scripts.ci.publish.subprocess.run")
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
result = generate_release_notes("v1.0.0")
assert "Release v1.0.0" in result
@patch("scripts.ci.publish.subprocess.run", side_effect=FileNotFoundError)
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
result = generate_release_notes("v1.0.0")
assert "Release v1.0.0" in result
assert "CHANGELOG.md" in result
@patch("scripts.ci.publish.shutil.which", return_value=None)
def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None:
result = generate_release_notes("v1.0.0")
assert "Release v1.0.0" in result
assert "CHANGELOG.md" in result
class TestBuildPackage:
@patch("scripts.ci.publish.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
build_package()
args, _ = mock_run.call_args
assert args[0][1] == "-m"
assert args[0][2] == "build"
@patch("scripts.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
with pytest.raises(click.ClickException) as exc:
build_package()
assert "build" in str(exc.value)
class TestPublishToPypi:
@patch("scripts.ci.publish.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
publish_to_pypi("pypi-tok")
args, _ = mock_run.call_args
assert "twine" in args[0]
assert "pypi-tok" in args[0]
@patch("scripts.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="upload failed")
with pytest.raises(click.ClickException) as exc:
publish_to_pypi("pypi-tok")
assert "PyPI" in str(exc.value)
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_full_flow_with_pypi(
self,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
mock_build.assert_called_once()
mock_publish.assert_called_once_with("pypi-tok")
mock_tea.create_release.assert_called_once_with(
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.build_package")
def test_without_pypi(
self,
mock_build: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
mock_build.assert_called_once()
mock_tea.create_release.assert_called_once()
assert "PYPI_TOKEN not set" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_repo_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_build_failure_raises_click(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_build.side_effect = click.ClickException("build failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "build" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_publish_failure_raises_click(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "publish" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_release_failure_raises_click(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_tea = MagicMock()
mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
-124
View File
@@ -1,124 +0,0 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import scripts.ci.push_badges as push_badges
class TestFetchLatestMaster:
def test_fetch_and_reset(self) -> None:
with patch("subprocess.run") as mock_run:
push_badges.fetch_latest_master("master")
# Should call git fetch and git reset --hard
calls = [str(c.args[0]) for c in mock_run.call_args_list]
assert any("fetch" in c for c in calls)
assert any("reset" in c for c in calls)
def test_custom_branch(self) -> None:
with patch("subprocess.run") as mock_run:
push_badges.fetch_latest_master("develop")
calls = [list(c.args[0]) for c in mock_run.call_args_list]
# fetch call should include the branch name
fetch_call = [c for c in calls if "fetch" in c][0]
assert "develop" in fetch_call
# reset call should include origin/develop
reset_call = [c for c in calls if "reset" in c][0]
assert "origin/develop" in reset_call
def test_fetch_failure_raises(self) -> None:
import subprocess
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
with pytest.raises(subprocess.CalledProcessError):
push_badges.fetch_latest_master()
class TestGenerateBadges:
def test_success(self, tmp_path: Path) -> None:
output_dir = tmp_path / ".badges"
output_dir.mkdir()
(output_dir / "badge1.svg").touch()
with patch("subprocess.run") as mock_run:
push_badges.generate_badges(str(output_dir))
mock_run.assert_called_once()
def test_no_badges_generated(self, tmp_path: Path) -> None:
output_dir = tmp_path / ".badges"
output_dir.mkdir()
with patch("subprocess.run"):
with pytest.raises(ClickException, match="No badge SVG files generated"):
push_badges.generate_badges(str(output_dir))
class TestPushToBadgesBranch:
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
svg = badges_dir / "badge1.svg"
svg.write_text("<svg></svg>")
with patch("subprocess.run") as mock_run:
push_badges.push_to_badges_branch(str(badges_dir))
# Should have called git config, checkout, rm, add, commit, push
assert mock_run.call_count >= 6
# Verify the SVG was copied to cwd
assert (tmp_path / "badge1.svg").exists()
class TestMain:
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with patch("subprocess.run"):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
assert result.exit_code == 0
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
runner = CliRunner()
with patch("subprocess.run"):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
assert result.exit_code != 0
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with patch("subprocess.run") as mock_run:
result = runner.invoke(
push_badges.main,
["--output-dir", str(badges_dir), "--branch", "develop"],
)
assert result.exit_code == 0
# Verify fetch was called with the custom branch
calls = [list(c.args[0]) for c in mock_run.call_args_list]
fetch_call = [c for c in calls if "fetch" in c][0]
assert "develop" in fetch_call
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
result = runner.invoke(push_badges.main, [])
assert result.exit_code != 0

Some files were not shown because too many files have changed in this diff Show More