Compare commits

..
47 Commits
Author SHA1 Message Date
devx-ci-bot f206a9cd8d release: v0.9.0 [skip ci] 2026-06-24 01:29:24 +02:00
emil b4b7428f9c DEVX-20: feat: extract Docker daemon start to tested Python module
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 51s
Post-merge / vikunja (push) Successful in 18s
Post-merge / badges (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 47s
2026-06-23 23:28:20 +00:00
devx-ci-bot 0d9e76a838 release: v0.8.5 [skip ci] 2026-06-24 01:02:21 +02:00
emil 034cbde2f7 DEVX-19: fix: retry pip install with --ignore-installed only on failure
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 39s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 36s
Post-merge / badges (push) Successful in 41s
2026-06-23 23:01:32 +00:00
devx-ci-bot e0abe6f176 release: v0.8.4 [skip ci] 2026-06-23 22:28:29 +00:00
emil 15f6837dc2 DEVX-18: fix: add --ignore-installed to pip in CI to bypass debian packages
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 39s
Post-merge / badges (push) Successful in 42s
2026-06-23 22:27:35 +00:00
devx-ci-bot b4dda91e24 release: v0.8.3 [skip ci] 2026-06-23 21:58:33 +00:00
emil 3e21e774f7 DEVX-17: fix: pass --break-system-packages to pip in CI environments
Post-merge / detect-type (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 14s
Post-merge / badges (push) Successful in 41s
2026-06-23 21:57:37 +00:00
emil 7c11215e57 DEVX-16: fix: lower check_test_speed threshold to 4 seconds
Post-merge / detect-type (push) Successful in 23s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 51s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / badges (push) Successful in 57s
Post-merge / vikunja (push) Successful in 19s
Post-merge / sync-wiki (push) Successful in 1m29s
2026-06-23 20:35:53 +00:00
devx-ci-bot 5384269c83 release: v0.8.2 [skip ci] 2026-06-23 21:44:03 +02:00
emil b3d0dd8ca7 DEVX-15: fix: encode spaces in pair commands to survive shell word-splitting
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 31s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 1m3s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 47s
2026-06-23 19:42:56 +00:00
devx-ci-bot a7dcaee5c6 release: v0.8.1 [skip ci] 2026-06-23 20:45:02 +02:00
emil 02f8d3757b DEVX-14: fix: set fresh MOLECULE_HOME per pair to avoid stale config cache
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / badges (push) Successful in 1m6s
Post-merge / release (push) Successful in 48s
Post-merge / vikunja (push) Successful in 21s
2026-06-23 18:43:58 +00:00
devx-ci-bot 4311fb7648 release: v0.8.0 [skip ci] 2026-06-23 20:11:18 +02:00
emil 2ead959fcf DEVX-14: feat: fix molecule platforms to use sleep infinity, add --platforms-file
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 20s
Post-merge / release (push) Successful in 47s
Post-merge / vikunja (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 50s
Post-merge / badges (push) Successful in 1m0s
2026-06-23 18:10:19 +00:00
devx-ci-bot 9a60009d29 release: v0.7.0 [skip ci] 2026-06-23 18:26:54 +02:00
emil c20dfd185a DEVX-13: feat: add per-test timing quality gate to check_test_speed
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 26s
Post-merge / release (push) Successful in 43s
Post-merge / vikunja (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / badges (push) Successful in 58s
2026-06-23 16:25:50 +00:00
devx-ci-bot 547fef4f27 release: v0.6.0 [skip ci] 2026-06-23 15:38:44 +02:00
emil 23183df7c7 DEVX-12: feat: add opentofu helpers, CLI entry points, shared utility, and CI improvements
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 1m22s
Post-merge / vikunja (push) Successful in 32s
Post-merge / badges (push) Successful in 50s
Post-merge / sync-wiki (push) Successful in 57s
2026-06-23 13:37:10 +00:00
devx-ci-bot f382408115 release: v0.5.0 [skip ci] 2026-06-23 03:36:10 +02:00
emil 19bec24f45 DEVX-10: feat: add tag verification, idempotency, and --verify mode to release script
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 1m0s
2026-06-23 01:28:27 +00:00
devx-ci-bot 37772f21a9 release: v0.4.4 [skip ci] 2026-06-22 23:45:40 +02:00
emil b07132e3c6 DEVX-9: fix: configurable task prefix and CWD-relative DOCS_DIR 2026-06-22 21:44:35 +00:00
devx-ci-bot 7b3b604c2c release: v0.4.3 [skip ci] 2026-06-22 23:13:27 +02:00
emil 53990dc10c DEVX-8: fix: expand DEFAULT_INFRASTRUCTURE to cover all common project files 2026-06-22 21:12:16 +00:00
devx-ci-bot 8ea044a942 release: v0.4.2 [skip ci] 2026-06-22 22:55:31 +02:00
emil 8411c92c95 DEVX-7: fix: make all warnings into errors across devx tools 2026-06-22 20:54:25 +00:00
devx-ci-bot 621b9936c8 release: v0.4.1 [skip ci] 2026-06-22 22:22:57 +02:00
emil 33063038a1 DEVX-6: fix: correct version tags, changelog, and release script recovery 2026-06-22 20:21:44 +00:00
devx-ci-bot 0c3e8a7b8d release: v0.4.0 [skip ci] 2026-06-22 21:04:55 +02:00
emil 6436c5dd38 DEVX-5: feat: add DEFAULT_INFRASTRUCTURE and configurable task prefix 2026-06-22 19:03:53 +00:00
devx-ci-bot 33b09c162d release: v0.3.0 [skip ci] 2026-06-22 20:23:30 +02:00
emil a2c856d8b2 DEVX-4: feat: add --no-ansible-collections option to setup tool 2026-06-22 18:22:26 +00:00
devx-ci-bot 7d9a081c92 release: v0.2.0 [skip ci] 2026-06-22 19:32:37 +02:00
emil 87d730d8be DEVX-3: feat: pluggable change classification framework 2026-06-22 17:31:30 +00:00
devx-ci-bot 89a165be46 release: v0.1.2 [skip ci] 2026-06-22 19:17:18 +02:00
emil 388c3df043 DEVX-2: fix: make sync-wiki and vikunja depend on release 2026-06-22 17:16:02 +00:00
devx-ci-bot ac8a1d3be4 release: v0.1.1 [skip ci] 2026-06-22 19:04:48 +02:00
emil 07cca5de36 DEVX-1: fix: disable push whitelist, allow direct pushes to master
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / release (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 1m0s
Post-merge / badges (push) Successful in 37s
2026-06-22 17:03:41 +00:00
devx-ci-bot 0800119795 release: v0.1.0 [skip ci] 2026-06-22 18:55:13 +02:00
emil 0c378ed8e2 DEVX-1: fix: allow release bot to push to protected master
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / release (push) Successful in 1m14s
Post-merge / badges (push) Successful in 38s
2026-06-22 16:53:42 +00:00
emil 6297b50601 DEVX-1: chore: trigger release after branch protection fix
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / configure-repo (push) Successful in 24s
Post-merge / release (push) Failing after 1m10s
Post-merge / badges (push) Successful in 37s
2026-06-22 16:43:43 +00:00
emil a7372a9169 DEVX-1: chore: re-trigger CI after Vikunja task title update
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 38s
Post-merge / sync-wiki (push) Successful in 46s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Failing after 1m12s
Post-merge / badges (push) Successful in 37s
2026-06-22 16:37:52 +00:00
emil 1a80967967 DEVX-1: chore: re-trigger CI after Vikunja task title update
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 59s
Post-merge / release (push) Failing after 1m6s
Post-merge / badges (push) Successful in 36s
2026-06-22 16:28:21 +00:00
emil 6ebb48f9e8 DEVX-1: fix: fix post-merge job failures (configure-repo, badges, notify-failure)
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / release (push) Failing after 1m5s
Post-merge / sync-wiki (push) Successful in 1m0s
Post-merge / badges (push) Failing after 31s
2026-06-22 16:08:27 +00:00
emil 31bfd23fea DEVX-1: fix: use python3 and venv python in workflows and Makefile
Post-merge / detect-type (push) Successful in 17s
Post-merge / validate-commit-msg (push) Successful in 16s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Failing after 13s
Post-merge / release (push) Failing after 1m5s
Post-merge / sync-wiki (push) Successful in 1m2s
Post-merge / badges (push) Failing after 28s
2026-06-22 15:52:45 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 60fd11419c feat: extract reusable dev/CI tools from GRM into devx package
Post-merge / detect-type (push) Failing after 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / badges (push) Failing after 25s
Port core modules (config, exceptions, i18n, api_clients, gitea_cli),
14 CI scripts, 6 dev tools, 5 molecule tools, CLI entry point, workflows,
Makefile, tests (784 tests, 100% coverage), and documentation from GRM.

The devx package is published to the Gitea PyPI registry and consumed
by GRM, infra, and other oblachno-oss projects as a pip dependency.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-22 17:01:20 +02:00
117 changed files with 22549 additions and 240 deletions
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="20" role="img"
aria-label="coverage: 100%">
<title>coverage: 100%</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="104" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="66" height="20" fill="#555"/>
<rect x="66" width="38" height="20" fill="#4c1"/>
<rect width="104" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="33" y="14">coverage</text>
<text x="85" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="76" height="20" role="img"
aria-label="docs: 100%">
<title>docs: 100%</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="76" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="38" height="20" fill="#555"/>
<rect x="38" width="38" height="20" fill="#4c1"/>
<rect width="76" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="19" y="14">docs</text>
<text x="57" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 879 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="20" role="img"
aria-label="python: 3.12">
<title>python: 3.12</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="90" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#007ec6"/>
<rect width="90" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="26" y="14">python</text>
<text x="71" y="14">3.12</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="20" role="img"
aria-label="code quality: A">
<title>code quality: A</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="124" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="94" height="20" fill="#555"/>
<rect x="94" width="30" height="20" fill="#4c1"/>
<rect width="124" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="47" y="14">code quality</text>
<text x="109" y="14">A</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="20" role="img"
aria-label="tests: 2310 passing">
<title>tests: 2310 passing</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="139" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="45" height="20" fill="#555"/>
<rect x="45" width="94" height="20" fill="#4c1"/>
<rect width="139" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="22" y="14">tests</text>
<text x="92" y="14">2310 passing</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="118" height="20" role="img"
aria-label="version: v0.50.0">
<title>version: v0.50.0</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="118" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="59" height="20" fill="#555"/>
<rect x="59" width="59" height="20" fill="#007ec6"/>
<rect width="118" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="29" y="14">version</text>
<text x="88" y="14">v0.50.0</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 903 B

+17
View File
@@ -0,0 +1,17 @@
# Gitea API token (required for CI scripts that interact with Gitea)
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
REPO_TOKEN=
# Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens
VIKUNJA_TOKEN=
# Vikunja project ID for this project (devx = 8)
DEVX_VIKUNJA_PROJECT_ID=8
# Override default API URLs (optional)
# DEVX_GITEA_API_URL=https://git.oblachno.oblachno.fyi/api/v1
# DEVX_VIKUNJA_API_URL=https://work.oblachno.oblachno.fyi/api/v1
# Override default language (optional, en is default)
# DEVX_LANG=en
+4
View File
@@ -0,0 +1,4 @@
# actionlint configuration for Gitea Actions workflows
self-hosted-runner:
labels:
- docker
+153
View File
@@ -0,0 +1,153 @@
name: CI
on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:
jobs:
quality:
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-quality
- name: Lint all
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate
make pytest-cov
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Documentation coverage check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.doc_coverage --fail-on-missing
- name: Translation completeness check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.check_translations
- name: Dependency security scan
run: |
. .venv/bin/activate
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
run: |
. .venv/bin/activate
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
detect-changes:
runs-on: docker
timeout-minutes: 10
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
- name: Detect changed paths
id: detect
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
release-dry-run:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-release
- name: Release dry-run validation
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run || true
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-ci
- name: Run automated PR review
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
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 .taskid file, validates the PR title, and squash-merges.
# No manual label or review needed — CI is the quality gate.
needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
"$REPOSITORY" \
"$PR_NUMBER"
+245
View File
@@ -0,0 +1,245 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# replaces separate workflows for release, wiki sync, badges, and
# Vikunja task updates.
#
# Job dependency graph:
#
# detect-type ──┬── release (skip if release commit)
# ├── badges (ALWAYS runs — even on release commits)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (needs release — skip if release commit/fails)
# └── vikunja (needs release — skip if release commit/fails)
#
# sync-wiki and vikunja depend on release succeeding so that the wiki
# and task tracker are only updated when the code is actually released.
# If release fails, they are skipped to avoid leaving the wiki or
# Vikunja in an inconsistent state with the codebase on master.
#
# The badges job depends on release so it picks up the latest version
# number. It 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, the release
# commit's post-merge run still updates badges (version badge picks
# up the new version). Other jobs skip. The tag push triggers publish.yml.
on:
push:
branches: [master]
jobs:
detect-type:
runs-on: docker
timeout-minutes: 10
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: python3 -m devx.ci.detect_release_commit
validate-commit-msg:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages click python-dotenv
python3 -m pip install --break-system-packages -e .
- name: Validate latest commit message
env:
PYTHONPATH: src
run: |
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
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
run: make setup-release
- name: Configure git
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
- name: Run release
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type, release]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
- name: Sync documentation to wiki
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}"
badges:
needs: [detect-type, release]
if: always()
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: master
token: ${{ secrets.REPO_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
run: make setup-ci
- name: Generate and push badges
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \
--commit "${{ github.sha }}"
vikunja:
needs: [detect-type, release]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
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
python3 -m pip install --break-system-packages -e .
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--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
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Ensure branch protection and labels
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \
--commit "${{ github.sha }}"
+49
View File
@@ -0,0 +1,49 @@
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 dependencies
run: |
python3 -m pip install --break-system-packages build twine requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Install CI tools
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
- name: Configure tea login
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
export PATH="$HOME/.local/bin:$PATH"
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ 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 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "publish" \
--commit "${{ github.sha }}"
+37
View File
@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
*.egg
dist/
build/
.eggs/
# Virtual environments
.venv/
venv/
# Testing
.coverage
htmlcov/
.pytest_cache/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
# OS
.DS_Store
Thumbs.db
# Molecule
.molecule/
# Badges
.badges/
+65
View File
@@ -0,0 +1,65 @@
repos:
- repo: local
hooks:
- id: validate-commit-msg
name: validate commit message
entry: env PYTHONPATH=src .venv/bin/python3 -m devx.ci.validate_commit_msg
language: system
stages: [commit-msg]
pass_filenames: true
- id: lint-ruff
name: ruff lint
entry: make lint-ruff
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: lint-format
name: ruff format check
entry: make lint-format
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: typecheck
name: pyright type check
entry: make typecheck
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: lint-bandit
name: bandit security scan
entry: make lint-bandit
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: workflow-lint
name: actionlint (workflow YAML)
entry: make workflow-lint
language: system
files: ^\.gitea/workflows/
types: [yaml]
pass_filenames: false
stages: [pre-commit]
- id: pytest-cov
name: pytest with 100% coverage
entry: make pytest-cov
language: system
types: [python]
pass_filenames: false
stages: [pre-push]
- id: commit-msg
name: validate commit message
entry: env PYTHONPATH=src .venv/bin/python3 -m devx.ci.validate_commit_msg
language: system
stages: [commit-msg]
pass_filenames: true
+1
View File
@@ -0,0 +1 @@
DEVX-20
+316
View File
@@ -0,0 +1,316 @@
# AGENTS.md — Project Conventions for devx
## Build & Test Commands
```bash
make setup # Create venv, install deps, set up hooks, install CI tools
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
make workflow-check # workflow-lint + workflow-dryrun
make clean # Remove caches, build artifacts, coverage data
```
`make setup` automatically installs all development tools:
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
## Workflow Verification (Before Push)
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 `python -m 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
without starting containers. Installed automatically by `make setup`.
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-quality` 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
devx is a reusable Python package providing development and CI/CD tools for oblachno-oss projects.
### Package Structure
```
src/devx/
├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command)
├── config.py # Configuration system (DEVX_ env vars, .env loading)
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types
├── translations.json # Translation strings (en, bg)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── _shared.py # Shared utilities (get_latest_tag)
│ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review
│ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── check_test_speed.py # Measure unit test execution time
│ ├── configure_repo.py # Branch protection and label setup
│ └── generate_badges.py # Badge SVG generation
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
├── molecule_all.py # Run all molecule scenarios locally
└── platforms.py # Supported molecule platforms
```
### Key Design Principles
- **Self-contained package** — `src/devx/` never imports from scripts outside the package
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*` or `python -m devx.tools.*`
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (NOT `.:src` since there are no scripts at repo root)
- **Config via env vars** — `DEVX_*` environment variables with `.env` file fallback
## PR Workflow (Mandatory)
Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings)
Branch protection and labels are automatically configured by
`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 must pass
- **Block force pushes**: No history rewriting on master
### 1. Create Vikunja Task
Create a task in Vikunja to get a `DEVX-N` identifier.
### 2. Create Branch
```bash
git checkout master && git pull
git checkout -b DEVX-N-short-description
```
### 3. Implement Changes
- Write code following conventions below
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
### 4. Commit (Conventional Commits)
Branch commits use conventional commit format (no `DEVX-N:` prefix):
```
feat: add new feature
fix: resolve bug
docs: update README
```
### 5. Push and Create PR
- **PR title format**: `DEVX-N: <vikunja task title>` (must match the Vikunja task title exactly)
- PR body: summary of changes, `Closes DEVX-N`
- Add `ready-to-merge` label **only after review is complete**
### 6. Review the PR
**Automated review (CI `pr-review` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review`. This job posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found):
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
- 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 agent **must** address all `REQUEST_CHANGES` issues before proceeding.
### 7. Address Review Comments
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, approve
the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists
3. Wait for all CI checks to pass (including the `pr-review` job)
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
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.
### Automated Release Pipeline
After a PR is merged to master, the **post-merge workflow**
(`.gitea/workflows/post-merge.yml`) runs automatically:
1. **detect-type** — Checks if the commit is a regular merge or a
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits.
2. **release** — Runs `python -m devx.ci.release` which:
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
- Uses **git-cliff** to calculate the next semver version from conventional commits
- Updates `__version__` in `src/devx/__init__.py` (single source of truth)
- Updates `CHANGELOG.md` with the new version section
- Runs `make lint-ruff` and `make pytest-cov` to verify the release is healthy
- Commits with `release: vX.Y.Z [skip ci]` prefix
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes both the commit and tag to master
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
5. **vikunja** — Marks the corresponding Vikunja task as done.
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
which builds and publishes the package to the Gitea PyPI registry.
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require a new release. The project classifies changes
using `python -m devx.ci.classify_changes`:
**Workflow-only paths** (infrastructure → no release needed):
- `.gitea/**` — Gitea Actions workflows
- `tests/**` — Test files
- `AGENTS.md`, `README.md`, `CHANGELOG.md` — Project docs
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml` — Config
- `.env.example`, `.gitignore` — Config
- `hooks/**` — Git hooks
**User-facing paths** (tool changes → release needed) — everything else:
- `src/devx/**` — Python package source (except `__init__.py`)
- `pyproject.toml` — Package metadata
- Any new file type not in the allowlist
## Script Separation and Import Rules
### Directory Layout
| Directory | Purpose | Release impact |
|-----------|---------|----------------|
| `src/devx/` | User-facing devx package | Changes trigger release |
| `src/devx/ci/` | CI/CD automation (run by workflows) | Part of package |
| `src/devx/tools/` | Developer tooling (run locally or by CI) | Part of package |
| `tests/` | Test files | Workflow-only (no release) |
### Import Rules
1. **`src/devx/` is self-contained** — the package never imports from outside `src/`
2. **CI modules** (`devx.ci.*`) may import from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, `devx.i18n`
3. **Tool modules** (`devx.tools.*`) may import from `devx.api_clients`, `devx.config`, `devx.gitea_cli`
4. **Cross-module imports** within `devx.ci.*` or `devx.tools.*` are allowed but must be documented
### PYTHONPATH Configuration
All workflows use `PYTHONPATH: src` — devx has no scripts at the repo root,
so `.:src` is not needed. The `src` directory is the sole import root.
```yaml
- name: Run script
env:
PYTHONPATH: src
run: python -m devx.ci.example
```
### tea CLI Integration
The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed
by `python -m devx.tools.install_tools` and configured by
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
**`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing:
- `create_issue()` — Create issues with labels
- `list_labels()` / `create_label()` / `add_label()` — Label management
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
- `create_release()` / `list_releases()` — Release management
### git-cliff Commit Preprocessing
Merge commits on master have the format `DEVX-N <conventional commit>`. The
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
prefix before parsing. This ensures all merged work appears in the changelog.
### Version Bumping Rules (git-cliff)
| Commit type | Version bump |
|-------------|-------------|
| `feat:` | minor (0.X.0) |
| `fix:` | patch (0.0.X) |
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0) |
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
The version source is `__version__` in `src/devx/__init__.py`, read by
setuptools via `dynamic = ["version"]` in `pyproject.toml`.
### Title Format Summary
| What | Format | Example |
|------|--------|---------|
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
| Branch commits | `<conventional commit>` | `feat: add release script` |
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
## Config System
devx uses environment variables with `.env` file fallback for configuration.
### DEVX_ Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `REPO_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
### Per-Project Overrides
Projects using devx can override the default API URLs and language by setting
`DEVX_*` environment variables or entries in their `.env` file. The config
system loads `.env` automatically via `python-dotenv`.
## Key Conventions
- Python 3.12+ required (ruff/pyright target `py312`)
- 100% test coverage required (`--cov-fail-under=100`)
- Conventional commits on feature branches (no `DEVX-N:` prefix)
- Branch names must include `DEVX-N` task ID
- Line length: 120 chars
- Secrets are passed via environment variables, never on the command line
- All user-facing strings wrapped in `_()` for i18n
+130
View File
@@ -0,0 +1,130 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.9.0] - 2026-06-23
### Features
- Extract Docker daemon start to tested Python module
## [0.8.5] - 2026-06-23
### Bug Fixes
- Retry pip install with --ignore-installed only on failure
## [0.8.4] - 2026-06-23
### Bug Fixes
- Add --ignore-installed to pip in CI to bypass debian packages
## [0.8.3] - 2026-06-23
### Bug Fixes
- Lower check_test_speed threshold to 4 seconds
- Pass --break-system-packages to pip in CI environments
## [0.8.2] - 2026-06-23
### Bug Fixes
- Encode spaces in pair commands to survive shell word-splitting
## [0.8.1] - 2026-06-23
### Bug Fixes
- Set fresh MOLECULE_HOME per pair to avoid stale config cache
## [0.8.0] - 2026-06-23
### Features
- Fix molecule platforms to use sleep infinity, add --platforms-file
## [0.7.0] - 2026-06-23
### Features
- Add per-test timing quality gate to check_test_speed
## [0.6.0] - 2026-06-23
### Features
- Add opentofu helpers, CLI entry points, shared utility, and CI improvements
## [0.5.0] - 2026-06-23
### Features
- Add tag verification, idempotency, and --verify mode to release script
## [0.4.4] - 2026-06-22
### Bug Fixes
- Configurable task prefix and CWD-relative DOCS_DIR
## [0.4.3] - 2026-06-22
### Bug Fixes
- Expand DEFAULT_INFRASTRUCTURE to cover all common project files
## [0.4.2] - 2026-06-22
### Bug Fixes
- Make all warnings into errors across devx tools
## [0.4.1] - 2026-06-22
### Bug Fixes
- Correct version tags, changelog, and release script recovery
## [0.4.0] - 2026-06-22
### Features
- Add DEFAULT_INFRASTRUCTURE and configurable task prefix
## [0.3.0] - 2026-06-22
### Features
- Add --no-ansible-collections option to setup tool
## [0.2.0] - 2026-06-22
### Features
- Pluggable change classification framework
## [0.1.2] - 2026-06-22
### Bug Fixes
- Make sync-wiki and vikunja depend on release
## [0.1.1] - 2026-06-22
### Bug Fixes
- Disable push whitelist, allow direct pushes to master
## [0.1.0] - 2026-06-22
### Features
- Extract reusable dev/CI tools from GRM into devx package
### Bug Fixes
- Use python3 and venv python in workflows and Makefile
- Fix post-merge job failures (configure-repo, badges, notify-failure)
- Allow release bot to push to protected master
+232
View File
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
grm
Copyright (C) 2026 emil
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
grm Copyright (C) 2026 emil
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+99
View File
@@ -0,0 +1,99 @@
.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-ruff lint-format typecheck lint-bandit lint-deps lint-all test test-unit pytest-cov clean workflow-lint workflow-dryrun workflow-check install-tools install-hooks activate-scripts
PYTHON := python3
VENV := .venv
BIN := $(VENV)/bin
all: setup
# Full setup for local development
setup: $(VENV)/bin/activate .env activate-scripts install-tools
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)"
# Lean setup for CI jobs (pytest + lint + runtime deps)
setup-ci: $(VENV)/bin/activate .env
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit --no-tea-login
# Setup for quality job (lint + test deps, actionlint tool)
setup-quality: $(VENV)/bin/activate .env install-tools
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \
export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit --no-tea-login
# Setup for release jobs (needs git-cliff, tea, lint tools)
setup-release: $(VENV)/bin/activate .env
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \
$(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)" --extras "ci,lint" --no-pre-commit
.env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; 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."
install-tools: $(VENV)/bin/activate
@$(BIN)/pip install -e '.' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools
lint-ruff:
$(BIN)/ruff check src/ tests/
lint-format:
$(BIN)/ruff format --check src/ tests/
typecheck:
$(BIN)/pyright
lint-bandit:
$(BIN)/bandit -r src/
lint: lint-ruff lint-format typecheck lint-bandit
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
lint-all: lint workflow-lint
workflow-lint:
@command -v actionlint >/dev/null 2>&1 || { echo "actionlint not found."; 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."; exit 1; }
@echo "Dry-running all workflows..."
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."
test-unit:
$(BIN)/pytest tests/unit/ -v --no-cov
pytest-cov:
$(BIN)/pytest tests/ -v --cov=src/devx --cov-report=term-missing --cov-fail-under=100
test: pytest-cov
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/ dist/ build/ *.egg-info/
+131
View File
@@ -0,0 +1,131 @@
# devx — Reusable Development & CI/CD Tools
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects. devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package.
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/python.svg)](https://www.python.org/downloads/)
## Installation
Install from the Gitea PyPI registry:
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Or add the registry to your `pip.conf` / `pyproject.toml` and install normally:
```bash
pip install devx
```
## Usage
### CI/CD Automation
devx provides CI/CD modules invoked via `python -m devx.ci.*`:
```bash
# Release automation (versioning, changelog, tagging)
python -m devx.ci.release
python -m devx.ci.release --dry-run
# Publish a release to the Gitea PyPI registry
python -m devx.ci.publish v1.0.0 oblachno-oss/devx
# Automated PR review
python -m devx.ci.pr_review 42 oblachno-oss/devx
# Auto-merge a PR (validates title, squash-merges)
python -m devx.ci.auto_merge feature-branch "DEVX-12: Add feature" oblachno-oss/devx 42
# Classify changes (user-facing vs workflow-only)
python -m devx.ci.classify_changes --base origin/master --head HEAD --github-output
# Sync documentation to Gitea wiki
python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
# Generate and push quality badges
python -m devx.ci.push_badges
# Check translation completeness
python -m devx.ci.check_translations
# Documentation coverage check
python -m devx.ci.doc_coverage --fail-on-missing
# Validate a commit message
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
# Notify on CI failure (creates a Gitea issue)
python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 --workflow ci --commit abc123
```
### Developer Tools
devx provides developer tooling invoked via `python -m devx.tools.*`:
```bash
# Set up a development environment (venv, deps, hooks)
python -m devx.tools.setup --bin .venv/bin
# Install CI tools (actionlint, git-cliff, act_runner, tea)
python -m devx.tools.install_tools
python -m devx.tools.install_tools --tool git-cliff --tool tea
# Check unit test speed
python -m devx.tools.check_test_speed --max-seconds 10
# Configure repository (branch protection, labels)
python -m devx.tools.configure_repo
```
### CLI
devx also provides a `devx` CLI command:
```bash
devx --help
devx --version
```
### Configuration
devx reads configuration from environment variables with `.env` file fallback:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_LANG` | `en` | Language (en, bg) |
| `REPO_TOKEN` | — | Gitea API token |
| `VIKUNJA_TOKEN` | — | Vikunja API token |
Copy `.env.example` to `.env` and fill in your tokens:
```bash
cp .env.example .env
```
## Development
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
cd devx
make setup # Create venv, install deps, hooks, CI tools
make lint-all # ruff + pyright + bandit + actionlint
make pytest-cov # Unit tests with 100% coverage
```
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and architecture details.
## License
GPL-3.0
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env fish
set -l script_dir (dirname (status --current-filename))
source "$script_dir/.venv/bin/activate.fish"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env zsh
0="${ZERO:-${0:#$ZSH_ARGZERO}}"
0="${${(M)0:#/*}:-$PWD/$0}"
source "${0:A:h}/.venv/bin/activate"
+71
View File
@@ -0,0 +1,71 @@
# git-cliff configuration for devx
# https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
trim = true
render_always = true
[git]
conventional_commits = true
filter_unconventional = true
require_conventional = false
split_commits = false
protect_breaking_commits = false
filter_commits = false
fail_on_unmatched_commit = false
use_branch_tags = false
topo_order = false
topo_order_commits = true
sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip DEVX-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^DEVX-\\d+:\\s+", replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
# Skip infrastructure-only commits — they don't affect users
{ message = "^doc", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
# Skip release commits — they are release artifacts, not features
{ message = "^release:", skip = true },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
# Skip anything that doesn't match above — safe default
{ message = ".*", skip = true },
]
[bump]
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
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="20" role="img"
aria-label="coverage: 100%">
<title>coverage: 100%</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="104" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="66" height="20" fill="#555"/>
<rect x="66" width="38" height="20" fill="#4c1"/>
<rect width="104" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="33" y="14">coverage</text>
<text x="85" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="76" height="20" role="img"
aria-label="docs: 100%">
<title>docs: 100%</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="76" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="38" height="20" fill="#555"/>
<rect x="38" width="38" height="20" fill="#4c1"/>
<rect width="76" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="19" y="14">docs</text>
<text x="57" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 879 B

+24
View File
@@ -0,0 +1,24 @@
# devx — Reusable Development & CI/CD Tools
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects.
## Overview
devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package. It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) project to be reusable across all oblachno-oss projects.
## Installation
Install from the Gitea PyPI registry:
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
## Architecture
- **Core modules** — config, exceptions, i18n, api_clients, gitea_cli
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review, classify_changes, etc.
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed, configure_repo, generate_badges
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible roles
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md) for full project conventions.
+6
View File
@@ -0,0 +1,6 @@
{
"index.md": "Home",
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
}
+50
View File
@@ -0,0 +1,50 @@
# Architecture
devx is a reusable Python package providing development and CI/CD tools for oblachno-oss projects.
## Package Structure
```
src/devx/
├── __init__.py # Version (single source of truth)
├── cli.py # Click-based CLI entry point (devx command)
├── config.py # Configuration system (DEVX_ env vars)
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types (DevxError, APIError)
├── translations.json # Translation strings (en, bg, de, ru, zh)
├── ci/ # CI/CD automation modules
├── tools/ # Developer tooling modules
└── molecule/ # Optional molecule testing helpers
```
## Core Modules
### cli.py
Click-based CLI entry point. Provides three command groups: `devx ci`, `devx tools`, `devx molecule`. Each subcommand delegates to the corresponding module via `_run_module()`.
### i18n.py
Simple i18n system using a JSON translations file. Supports en, bg, de, ru, zh. Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a custom JSON file.
### exceptions.py
Custom exception hierarchy: `DevxError` (base), `APIError` (HTTP errors with status code and message).
### api_clients.py
HTTP API clients with connection pooling and retry logic:
- `GiteaClient` — Gitea REST API (branch protection, labels, issues, PRs, releases, reviews)
- `VikunjaClient` — Vikunja REST API (tasks, projects, comments)
Both clients retry on transient errors (429, 5xx, connection errors) with exponential backoff.
### config.py
Configuration constants with env-var overrides (`DEVX_` prefix). Includes API URLs, timeouts, retry settings, task prefix regex, and conventional commit regex.
### gitea_cli.py
Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for structured data. Used by CI scripts for Gitea API operations (issues, labels, PRs, releases, reviews).
+85
View File
@@ -0,0 +1,85 @@
# CI/CD Workflow
devx uses Gitea Actions for CI/CD automation. The workflow replicates GRM's automated pipeline but without molecule tests.
## Workflows
### CI (`ci.yml`)
Runs on pull requests. Jobs:
1. **quality** — lint (ruff, pyright, bandit, actionlint), unit tests with 100% coverage, test speed check, doc coverage, translation check, dependency scan
2. **detect-changes** — classify changes as user-facing or workflow-only
3. **release-dry-run** — dry-run the release script (only if user-facing changes)
4. **pr-review** — automated PR review
5. **auto-merge** — squash-merge PR when all checks pass
### Post-merge (`post-merge.yml`)
Runs on every push to master. Jobs:
1. **detect-type** — check if commit is a release commit
2. **validate-commit-msg** — validate conventional commit format
3. **release** — calculate next version, update changelog, tag, push
4. **sync-wiki** — sync docs to Gitea wiki
5. **badges** — generate and push quality badges
6. **vikunja** — mark Vikunja task as done
7. **configure-repo** — ensure branch protection and labels
### Publish (`publish.yml`)
Runs on tag pushes (`v*`). Builds the package, publishes to Gitea PyPI registry, and creates a Gitea release.
## CI Scripts
### auto_merge.py
Auto-merge PR when all CI checks pass. Reads task ID from `.taskid`, validates PR title format, checks Vikunja task exists, squash-merges with `DEVX-N <conventional commit>` title.
### release.py
Automated release using git-cliff. Calculates next semver version from conventional commits, updates `__version__` in `__init__.py`, updates `CHANGELOG.md`, runs lint and tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes.
### publish.py
Builds package with `python -m build`, publishes to Gitea PyPI registry via twine, creates Gitea release with git-cliff-generated notes.
### pr_review.py
Automated PR review. Checks architecture compliance, best practices, security, i18n, resource management, documentation, test coverage, and commit conventions. Posts inline comments and structured review.
### notify_failure.py
Creates a Gitea issue when a CI workflow fails. Uses tea CLI for issue creation with failure labels.
### post_merge.py
Updates Vikunja task after a merge to master. Extracts task ID from commit message, marks task as done, posts a comment with the merge SHA.
### classify_changes.py
Classifies git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes. Patterns are configurable.
### discover_runners.py
Discovers available Gitea Actions runners at repo, org, and instance levels. Generates a dynamic matrix for parallel job distribution.
### detect_release_commit.py
Detects whether the latest git commit is a release commit. Writes `is-release=true` or `is-release=false` to GitHub output.
### push_badges.py
Generates SVG badge files from project metrics (tests, coverage, quality, version). Pushes to `badges` branch and updates README with cache-busting commit SHA URLs.
### distribute_molecule.py
Distributes molecule (scenario, platform) pairs across N parallel runners. Discovers scenarios under `ansible/roles/*/molecule/`.
### molecule_ci_guard.py
Runs molecule tests sequentially while polling Gitea for other runner failures. Aborts if another runner fails the same job.
### validate_commit_msg.py
Validates commit messages. On feature branches: conventional commits only (no `DEVX-N` prefix). On master: must have `DEVX-N` prefix from auto-merge.
+134
View File
@@ -0,0 +1,134 @@
# CLI Commands
devx provides a CLI with three command groups: `ci`, `tools`, and `molecule`.
## CI Commands
### `devx ci auto-merge`
Auto-merge a PR when all CI checks pass. Validates PR title, checks Vikunja task, squash-merges.
### `devx ci check-translations`
Check translation files for gaps, dead keys, and missing languages.
### `devx ci classify-changes`
Classify git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes.
### `devx ci detect-release-commit`
Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip ci]`).
### `devx ci discover-runners`
Discover available Gitea Actions runners for dynamic job distribution.
### `devx ci distribute-files`
Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners.
### `devx ci doc-coverage`
Check documentation coverage for CLI commands and major modules.
### `devx ci integration-guard`
Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected.
### `devx ci merge-junit`
Merge multiple JUnit XML reports from parallel runners into a single consolidated report.
### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails.
### `devx ci post-merge`
Update Vikunja task after a merge to master.
### `devx ci pr-review`
Run automated PR review: check architecture compliance, best practices, and quality.
### `devx ci publish`
Build package, publish to Gitea PyPI registry, and create Gitea release.
### `devx ci push-badges`
Generate badge SVG files and push them to the `badges` branch.
### `devx ci release`
Automated release: calculate next version, update files, tag, and push.
### `devx ci sync-wiki`
Sync documentation from `docs/` to the Gitea wiki.
### `devx ci validate-commit-msg`
Validate commit messages for conventional commit format.
## Tools Commands
### `devx tools check-test-speed`
Run unit tests and enforce execution-time budgets:
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
```bash
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
```
### `devx tools configure-repo`
Configure repository: branch protection + labels via Gitea API.
### `devx tools generate-badges`
Generate self-contained SVG badge files from project metrics.
### `devx tools generate-cliff-config`
Generate a `cliff.toml` configuration file with the correct task ID prefix.
Eliminates the need to manually duplicate and maintain cliff.toml across
repos that use devx.
```bash
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
```
### `devx tools install-checkmake`
Install checkmake (Makefile linter) if not already present.
### `devx tools install-tools`
Install CI/CD development tools: actionlint, git-cliff, act_runner, tea.
### `devx tools setup`
Project setup: install Python deps and pre-commit hooks.
## Molecule Commands
### `devx molecule distribute`
Distribute molecule test pairs across parallel runners.
### `devx molecule discover-runners`
Discover available Gitea Actions runners for molecule tests.
### `devx molecule guard`
Run molecule tests sequentially with CI failure polling.
### `devx molecule all`
Run all molecule scenarios on all supported OS platforms.
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# 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
export PYTHONPATH=src
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# pre-push hook: run full test suite with 100% coverage enforcement.
set -e
export PYTHONPATH=src
.venv/bin/pytest tests/ -v --cov=src/devx --cov-report=term-missing --cov-fail-under=100
+126
View File
@@ -0,0 +1,126 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "devx"
dynamic = ["version"]
description = "Reusable development and CI/CD tools for oblachno-oss projects"
readme = "README.md"
license = {text = "GPL-3.0"}
requires-python = ">=3.12"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
]
dependencies = [
"requests>=2.34.2",
"python-dotenv>=1.2.2",
"click>=8.4.1",
]
[project.scripts]
devx = "devx.cli:cli"
[tool.setuptools.dynamic]
version = {attr = "devx.__version__"}
[project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
]
# Lint and type-checking tools (quality job)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
"bandit>=1.8.2",
"pip-audit>=2.10",
"pre-commit>=4.6.0",
]
# Molecule testing (optional — for projects with Ansible roles)
molecule = [
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"ansible-lint>=26.4.0",
"ansible>=14.0.0",
]
# Full dev environment (local development)
dev = [
"devx[ci,lint]",
"build>=1.3.0",
"twine>=6.2.0",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
devx = ["translations.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "--cov=src/devx --cov-report=term-missing --cov-fail-under=100"
markers = [
"integration: marks tests as integration tests (not counted in coverage)",
]
[tool.ruff]
target-version = "py312"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
ignore = ["SIM117"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pyright]
include = ["src"]
pythonVersion = "3.12"
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
# ---------------------------------------------------------------------------
# Change classification — determines which changes trigger a release
# ---------------------------------------------------------------------------
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
# lint config, etc.) that applies to any Python project. We only specify
# what's different about devx.
#
# Rule priority (first match wins):
# 1. user_facing_overrides (safety — highest priority)
# 2. infrastructure_overrides (explicit per-file)
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe)
[tool.devx.classify]
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
# Project-specific infrastructure paths (merged with defaults).
# devx has no additional infrastructure paths — everything not in the
# defaults is user-facing (src/devx/**, pyproject.toml, translations.json).
infrastructure = []
# 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)
#
# NOTE: api_clients.py is NOT here — it's used by devx's CI modules
# (auto_merge.py, release.py, pr_review.py, etc.) which consumer projects
# call via `python -m devx.ci.*`. Changes to api_clients.py affect consumer
# projects' CI behavior, so it IS user-facing.
infrastructure_overrides = [
"src/devx/__init__.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty — add when an infrastructure pattern is too broad)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure)
[tool.devx.classify.tags]
# No tags needed for devx itself — it has no ansible/ directory
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="20" role="img"
aria-label="python: 3.12">
<title>python: 3.12</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="90" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#007ec6"/>
<rect width="90" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="26" y="14">python</text>
<text x="71" y="14">3.12</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="20" role="img"
aria-label="code quality: A">
<title>code quality: A</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="124" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="94" height="20" fill="#555"/>
<rect x="94" width="30" height="20" fill="#4c1"/>
<rect width="124" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="47" y="14">code quality</text>
<text x="109" y="14">A</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

+3
View File
@@ -0,0 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.9.0"
+349
View File
@@ -0,0 +1,349 @@
"""Reusable HTTP API clients for Gitea and Vikunja."""
from __future__ import annotations
import json
import logging
import time
from typing import Any
import requests
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.exceptions import APIError
logger = logging.getLogger("devx")
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
"""Extract status code and message from an HTTPError response."""
response = getattr(e, "response", None)
status = response.status_code if response is not None else 0
try:
body: dict[str, Any] = response.json() if response is not None else {}
message: str = body.get("message", str(e))
except (json.JSONDecodeError, ValueError, AttributeError):
message = str(e)
return status, message
def _is_retryable(e: Exception) -> bool:
"""Check if an exception is a transient error worth retrying."""
if isinstance(e, requests.ConnectionError):
return True
if isinstance(e, requests.HTTPError):
status, _ = _parse_error(e)
return status in RETRY_STATUS_CODES
return isinstance(e, requests.Timeout)
class GiteaClient:
"""Low-level Gitea REST API client with connection pooling."""
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
self._base_url = base_url.rstrip("/")
self._owner = owner
self._repo = repo
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
)
def _url(self, path: str) -> str:
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path)
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
# Should not reach here, but just in case
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
# -- repo settings --
def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
"""Update repository settings (e.g. auto-delete branch after merge)."""
r = self._request("PATCH", "", json=settings)
return r.json()
# -- branch protection --
def list_branch_protections(self) -> list[dict[str, Any]]:
r = self._request("GET", "/branch_protections")
return r.json()
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("POST", "/branch_protections", json=config)
return r.json()
def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("PATCH", f"/branch_protections/{branch}", json=config)
return r.json()
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
"""Idempotent: create or update branch protection for the given branch."""
existing = self.list_branch_protections()
for p in existing:
if p.get("branch_name") == branch:
update_config = {k: v for k, v in config.items() if k != "branch_name"}
return self.update_branch_protection(branch, update_config)
return self.create_branch_protection(config)
# -- labels --
def list_labels(self) -> list[dict[str, Any]]:
r = self._request("GET", "/labels")
return r.json()
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
r = self._request(
"POST",
"/labels",
json={"name": name, "color": color, "description": description},
)
return r.json()
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
"""Idempotent: create label if it doesn't already exist."""
labels = self.list_labels()
for label in labels:
if label["name"] == name:
return None
return self.create_label(name, color, description)
def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]:
"""Create a new issue in the repository.
Args:
labels: List of label IDs (integers, not names).
"""
payload: dict[str, Any] = {"title": title, "body": body}
if labels:
payload["labels"] = labels
r = self._request("POST", "/issues", json=payload)
return r.json()
# -- pulls / releases --
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch labels currently attached to a pull request."""
r = self._request("GET", f"/issues/{pr_number}/labels")
return r.json()
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit.
Uses the combined status endpoint (/commits/{sha}/status) which
returns one entry per context (the latest), deduplicated server-side.
The plural endpoint (/commits/{sha}/statuses) returns every historical
entry including stale "pending" ones that never got updated.
"""
r = self._request("GET", f"/commits/{sha}/status")
data = r.json()
return data.get("statuses", [])
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
"""Fetch pull request details including mergeable state."""
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the list of files changed in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/files")
return r.json()
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the commits included in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/commits")
return r.json()
def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch reviews posted on a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/reviews")
return r.json()
def create_review(
self,
pr_number: str | int,
event: str = "COMMENT",
body: str = "",
comments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Post a review on a pull request.
Args:
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
body: Top-level review body text.
comments: Line-level comments with ``path``, ``body``,
``new_position`` (and optionally ``old_position``).
"""
# Map common event names to Gitea API values
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
gitea_event = event_map.get(event, event)
payload: dict[str, Any] = {"event": gitea_event, "body": body}
if comments:
payload["comments"] = comments
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
return r.json()
def create_release(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
payload = {
"tag_name": tag,
"name": name or tag,
"body": body,
"draft": draft,
"prerelease": prerelease,
}
r = self._request("POST", "/releases", json=payload)
return r.json()
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
"""Fetch a release by its tag name. Returns None if not found."""
try:
r = self._request("GET", f"/releases/tags/{tag}")
return r.json()
except APIError:
return None
def create_release_idempotent(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
"""Create a release, or return the existing one if it already exists.
This is idempotent — safe to call multiple times for the same tag.
"""
existing = self.get_release_by_tag(tag)
if existing:
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
return existing
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling."""
def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/")
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {token}"})
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}"
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params)
return r.json()
def get_task(self, task_id: int) -> dict[str, Any]:
"""Fetch a single task by its numeric ID."""
r = self._request("GET", f"/tasks/{task_id}")
return r.json()
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
"""List tasks in a specific project (more efficient than listing all tasks)."""
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
def update_task(self, task_id: int, **fields: Any) -> None:
self._request("POST", f"/tasks/{task_id}", json=fields)
View File
+18
View File
@@ -0,0 +1,18 @@
"""Shared utilities for CI modules."""
from __future__ import annotations
import subprocess # nosec B404
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()
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Auto-merge PR when all CI checks pass.
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
(falling back to branch name extraction for backwards compatibility),
validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID.
PR title format: ``{PREFIX}-N: <vikunja task title>``
Merge commit format: ``{PREFIX}-N <conventional commit message>``
The ``{PREFIX}`` is determined by ``DEVX_TASK_PREFIX`` (default: ``DEVX``).
Each project sets its own prefix (e.g., ``GRM``, ``INFRA``).
The conventional commit message is extracted from the PR commits.
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 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
"""
import os
import re
import subprocess # nosec B404
from pathlib import Path
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient
from devx.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
GITEA_API_URL,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.exceptions import APIError
from devx.i18n import _
TASKID_FILE = ".taskid"
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv()
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
def read_taskid(branch: str) -> str:
"""Read task ID from .taskid file, falling back to branch name extraction.
The .taskid file is a simple text file containing just the task ID
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
branch name as a backwards-compatibility fallback.
"""
path = Path(TASKID_FILE)
if path.exists():
task_id = path.read_text(encoding="utf-8").strip()
if task_id:
return task_id
# Fallback: extract from branch name
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def extract_task_id(branch: str) -> str:
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
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: ``{PREFIX}-N: <vikunja task title>``
"""
if not PR_TITLE_RE.match(pr_title):
raise click.ClickException(
_(
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n"
" Expected: {task_id}: <task title>\n"
" Got: {pr_title}",
prefix=TASK_PREFIX,
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 DEVX-N identifier.
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
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
raise click.ClickException(
_(
"Could not find Vikunja task {task_id} in project {project_id}. "
"Every PR must have a corresponding Vikunja task.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
"""Validate that PR title matches the Vikunja task title.
Raises ClickException if VIKUNJA_TOKEN is not set, the task is not found,
or the title doesn't match.
"""
vikunja_title = get_vikunja_task_title(task_id)
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 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 ""
@click.command()
@click.argument("branch")
@click.argument("pr_title")
@click.argument("repo")
@click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
# Validate PR number is an integer
try:
pr_num = int(pr_number)
except ValueError:
raise click.ClickException(_("PR number must be an integer, got: {pr_number}", pr_number=pr_number)) from None
# Validate repo format
if "/" not in repo:
raise click.ClickException(_("Repo must be in 'owner/name' format, got: {repo}", repo=repo))
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
task_id = read_taskid(branch)
if not task_id:
raise click.ClickException(
_(
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
branch=branch,
)
)
click.echo(_("Task ID: {task_id}", task_id=task_id))
validate_pr_title(pr_title, task_id)
validate_pr_title_matches_vikunja(pr_title, task_id)
# Build merge title: DEVX-N: <conventional commit message>
commits = client.get_pr_commits(pr_num)
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_num, 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", "config", "user.name", "devx-ci-bot"])
run_cmd(["git", "config", "user.email", "devx-ci-bot@oblachno.fyi"])
run_cmd(["git", "fetch", "origin", "master"])
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_num, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
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_num,
merge_title=merge_title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Check translation files for gaps, dead keys, and missing languages.
Validates translation files against the Python source code that uses them.
By default, checks ``src/devx/translations.json`` against keys used in
``src/devx/**/*.py``. Additional translation sets can be checked by
passing ``--translations`` flags (each pointing to a JSON file; the
source directory is inferred as the parent of the translations file).
Checks performed (all fail with exit code 1 on error):
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
translations file.
- **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). This is an error — all supported languages
must have translations for every key.
Usage::
python3 -m devx.ci.check_translations
python3 -m devx.ci.check_translations --translations path/to/translations.json
"""
from __future__ import annotations
import ast
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
# Default translation set: devx package itself
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
# Functions that wrap _() and receive a translation key as first arg.
# Their string-literal arguments should be treated as translation keys.
_I18N_WRAPPERS = {"_handle_errors"}
# Known dynamic keys used via _(variable) that can't be detected by AST.
# These are status strings set as variable values and passed to _().
DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"}
@dataclass
class TranslationCheckResult:
"""Result of a translation check for one translation set."""
name: str
src_dir: Path
trans_file: Path
used_keys: set[str] = field(default_factory=set)
defined_keys: set[str] = field(default_factory=set)
missing_keys: set[str] = field(default_factory=set)
dead_keys: set[str] = field(default_factory=set)
missing_langs: dict[str, list[str]] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def extract_keys(filepath: Path) -> set[str]:
"""Extract translation keys from a Python file using AST.
Detects:
- ``_("key")`` calls with string-literal first argument
- ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``)
"""
try:
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
except SyntaxError:
return set()
keys: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if (
isinstance(func, ast.Name)
and func.id in ("_", *_I18N_WRAPPERS)
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
keys.add(node.args[0].value)
return keys
def collect_keys(src_dir: Path) -> set[str]:
"""Collect all translation keys from .py files in a directory tree.
Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used
via ``_(variable)`` and can't be detected by AST scanning.
"""
keys: set[str] = set()
for pyfile in src_dir.rglob("*.py"):
if pyfile.name == "i18n.py":
continue
keys |= extract_keys(pyfile)
# Add dynamic keys for the default source directory
if src_dir == DEFAULT_SRC_DIR:
keys |= DYNAMIC_KEYS
return keys
def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult:
"""Check one translation set for gaps and dead keys."""
result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file)
# Collect used keys from source code
result.used_keys = collect_keys(src_dir)
# Load defined keys from translations file
if not trans_file.exists():
result.errors.append(f"Translations file not found: {trans_file}")
return result
translations = json.loads(trans_file.read_text(encoding="utf-8"))
result.defined_keys = set(translations.keys())
# Check for missing keys (used in code but not in translations)
result.missing_keys = result.used_keys - result.defined_keys
for key in sorted(result.missing_keys):
result.errors.append(f"Missing key in {name}: {key!r}")
# Check for dead keys (in translations but not used in code)
result.dead_keys = result.defined_keys - result.used_keys
for key in sorted(result.dead_keys):
result.errors.append(f"Dead key in {name}: {key!r}")
# Check for missing languages — this is an error, not a warning.
# All supported languages must have translations for every key.
for key, langs in translations.items():
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
if missing:
result.missing_langs[key] = missing
result.errors.append(f"Missing languages {missing} for key {key!r} in {name}")
return result
def print_result(result: TranslationCheckResult) -> None:
"""Print check results in a human-readable format."""
click.echo(f"\n=== {result.name} ===")
click.echo(f" Source dir: {result.src_dir}")
click.echo(f" Translations: {result.trans_file}")
click.echo(f" Used keys: {len(result.used_keys)}")
click.echo(f" Defined keys: {len(result.defined_keys)}")
click.echo(f" Missing keys: {len(result.missing_keys)}")
click.echo(f" Dead keys: {len(result.dead_keys)}")
click.echo(f" Missing langs: {len(result.missing_langs)} keys")
for err in result.errors:
click.echo(f" ERROR: {err}", err=True)
for warn in result.warnings:
click.echo(f" WARN: {warn}", err=True)
if not result.errors and not result.warnings:
click.echo(" All good!")
@click.command()
@click.option(
"--translations",
"translations",
multiple=True,
type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
)
def main(translations: tuple[Path, ...]) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
if not translations:
# Default: check the devx package's own translations
results = [
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
]
else:
results = []
for trans_file in translations:
# Infer source directory as the parent of the translations file
src_dir = trans_file.parent
name = trans_file.parent.name
results.append(check_translation_set(name, src_dir, trans_file))
has_errors = False
for result in results:
print_result(result)
if result.errors:
has_errors = True
click.echo()
if has_errors:
click.echo("FAIL: Translation check found errors.", err=True)
sys.exit(1)
click.echo("PASS: All translations are complete and up to date.")
if __name__ == "__main__": # pragma: no cover
main()
+724
View File
@@ -0,0 +1,724 @@
#!/usr/bin/env python3
"""Classify git changes as user-facing or infrastructure.
Determines whether changes between two git refs (e.g., last tag and HEAD)
affect the published package (user-facing) or only the CI/CD infrastructure
(workflow-only). This is used by:
- **release.py** — skips release when only infrastructure files changed
- **CI workflow** — skips molecule tests and release dry-run when only
infrastructure files changed
== Design Philosophy ==
**Safe-by-default**: Any file that doesn't match a rule defaults to
user-facing. This prevents new file types from accidentally skipping
releases — a critical safety property. When in doubt, release.
**Framework-provided defaults**: The framework ships with
``DEFAULT_INFRASTRUCTURE`` — a curated list of paths that are
infrastructure for ANY Python project (CI workflows, tests, docs,
lint config, etc.). Projects inherit these automatically and only
need to specify what's *different* about their project.
**Config-driven**: Classification rules are read from ``[tool.devx.classify]``
in ``pyproject.toml``. No project needs to modify the framework code.
Each project declares its own paths; the framework handles the logic.
**Layered rules** (evaluated in priority order):
1. **User-facing overrides** (highest priority — safety override)
Files that match infrastructure patterns but MUST be treated as
user-facing. Use this when an infrastructure pattern is too broad.
2. **Infrastructure overrides**
Files that would default to user-facing but are actually
infrastructure (e.g., ``src/pkg/__init__.py`` which only contains
``__version__`` — a release artifact, not user-facing code).
3. **Infrastructure patterns** (deny-list)
Path globs matching infrastructure files. This is the union of
``DEFAULT_INFRASTRUCTURE`` and the project's ``infrastructure`` list.
Changes to these don't trigger a release.
4. **Default**: user-facing (lowest priority — safe default)
**Tag system** (orthogonal to release impact):
Projects can define custom tags (e.g., ``ansible``, ``docs``) for CI
conditional execution. A file can be both infrastructure (no release)
and tagged ``ansible`` (run molecule tests). Tags are evaluated
independently of the user-facing/infrastructure classification.
The ``--check`` CLI option accepts any tag name defined in the config,
and ``--github-output`` writes ``<tag>-changed`` for each configured tag.
== Configuration ==
In ``pyproject.toml``::
[tool.devx.classify]
# Whether to merge with DEFAULT_INFRASTRUCTURE (default: true).
# Set to false to specify all patterns explicitly.
# use_defaults = true
# Project-specific infrastructure paths (merged with defaults).
# Only list paths NOT already in DEFAULT_INFRASTRUCTURE.
infrastructure = [
"scripts/**", # e.g., if scripts/ is dev-only tooling
]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure
infrastructure_overrides = [
"src/mypkg/__init__.py", # only contains __version__
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty by default)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
== What counts as "user-facing" ==
A change is user-facing if it affects the behavior of the installed
package. For a library/CLI tool, this means:
- Source code in ``src/`` (except ``__init__.py`` which only holds
``__version__``)
- Package metadata (``pyproject.toml`` — dependencies, entry points)
- Ansible roles, playbooks, templates (if the project ships Ansible)
- Translation files (user-visible messages)
- Any file not explicitly classified as infrastructure
A change is infrastructure if it only affects the project's own
development/CI environment:
- CI/CD workflows (``.gitea/**``, ``.github/**``)
- Tests (``tests/**``)
- Documentation (``docs/**``, ``README.md``, ``CHANGELOG.md``)
- Linting/formatting config (``.ruff.toml``, ``.pre-commit-config.yaml``)
- Build tooling (``Makefile``, ``cliff.toml``)
- Git hooks (``hooks/**``)
- Generated scripts (``activate.sh``, ``activate.fish``, ``activate.zsh``)
== Glob Syntax ==
Patterns support standard glob syntax:
- ``**`` matches any number of path segments (including zero)
- ``*`` matches any characters within a single path segment
- ``?`` matches a single character within a single path segment
- Everything else is matched literally
Examples:
- ``.gitea/**`` matches ``.gitea/workflows/ci.yml``, ``.gitea/actionlint.yaml``
- ``tests/**`` matches ``tests/unit/test_cli.py``, ``tests/conftest.py``
- ``src/devx/__init__.py`` matches exactly that file
- ``Makefile`` matches exactly that file
Usage:
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
python3 -m devx.ci.classify_changes --base v0.3.0 --head HEAD
python3 -m devx.ci.classify_changes --check ansible --quiet
python3 -m devx.ci.classify_changes --github-output
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import click
from devx.ci._shared import get_latest_tag
from devx.i18n import _
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FileClassification:
"""Result of classifying a single file.
Attributes:
path: The file path relative to repo root.
is_user_facing: True if changes to this file require a release.
reason: Human-readable explanation of the classification.
matched_rule: Which rule matched (e.g., "infrastructure: .gitea/**").
None if the default rule was used.
tags: Custom category tags (e.g., {"ansible"}).
"""
path: str
is_user_facing: bool
reason: str
matched_rule: str | None
tags: frozenset[str] = frozenset()
@dataclass
class ClassificationResult:
"""Result of classifying a set of changed files.
Attributes:
files: Per-file classification details.
user_facing: List of file paths classified as user-facing.
infrastructure: List of file paths classified as infrastructure.
tags: Dict mapping tag name to list of file paths matching that tag.
"""
files: list[FileClassification] = field(default_factory=list)
user_facing: list[str] = field(default_factory=list)
infrastructure: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
@property
def has_user_facing(self) -> bool:
"""True if any user-facing files were found."""
return bool(self.user_facing)
def has_tag(self, tag: str) -> bool:
"""True if any files matched the given tag."""
return bool(self.tags.get(tag))
# ---------------------------------------------------------------------------
# Glob matching
# ---------------------------------------------------------------------------
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Convert a glob pattern to a compiled regex.
Supports:
- ``**`` -> matches any number of path segments (including zero)
- ``*`` -> matches any chars within a single path segment
- ``?`` -> matches a single char within a path segment
- All other characters are matched literally
"""
# Handle ** at the end (e.g., ".gitea/**")
# ** matches anything including slashes
parts: list[str] = []
i = 0
while i < len(pattern):
c = pattern[i]
if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
parts.append(".*")
i += 2
# Skip trailing slash after **
if i < len(pattern) and pattern[i] == "/":
i += 1
elif c == "*":
parts.append("[^/]*")
i += 1
elif c == "?":
parts.append("[^/]")
i += 1
else:
parts.append(re.escape(c))
i += 1
return re.compile("^" + "".join(parts) + "$")
def _matches_glob(file_path: str, pattern: str) -> bool:
"""Check if a file path matches a glob pattern.
Also supports prefix matching: if the pattern ends with ``/``,
any file starting with that prefix matches. This is a convenience
for patterns like ``.gitea/`` (equivalent to ``.gitea/**``).
"""
# Prefix matching for patterns ending with /
if pattern.endswith("/") and (file_path.startswith(pattern) or file_path == pattern.rstrip("/")):
return True
return _glob_to_regex(pattern).match(file_path) is not None
# ---------------------------------------------------------------------------
# Classifier
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Default infrastructure patterns
# ---------------------------------------------------------------------------
# Common infrastructure paths that apply to ANY Python project using devx.
# Projects inherit these automatically and only need to specify project-specific
# paths in their [tool.devx.classify] section.
#
# Rationale: these files/directories are development tooling, CI/CD config,
# or generated artifacts. Changes to them don't affect the installed package's
# behavior, so they don't warrant a release.
DEFAULT_INFRASTRUCTURE: list[str] = [
# CI/CD workflow definitions
".gitea/**",
".github/**",
# Test files
"tests/**",
# Documentation
"docs/**",
# Git hooks
"hooks/**",
# Build tooling
"Makefile",
"cliff.toml",
"uv.lock",
# Linting / formatting config
".pre-commit-config.yaml",
".ruff.toml",
".ansible-lint",
".checkmake.ini",
".editorconfig",
# Environment templates (not the actual .env which is gitignored)
".env.example",
# Git config
".gitignore",
".gitattributes",
# Project-level documentation (not part of the installed package)
"AGENTS.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
"CONTRIBUTING.md",
"CODE_OF_CONDUCT.md",
"REVIEW_CHECKLIST.md",
# Agent/CI tooling config (not part of the installed package)
".devin/**",
# Generated venv activation scripts (created by `make setup`)
"activate.sh",
"activate.fish",
"activate.zsh",
# CI task tracking file (written by CI, not by developers)
".taskid",
]
@dataclass
class ClassifierConfig:
"""Configuration for the change classifier.
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
By default, the framework's ``DEFAULT_INFRASTRUCTURE`` patterns are
merged with the project's ``infrastructure`` list. Set
``use_defaults = false`` to disable defaults and specify all
patterns explicitly.
Attributes:
infrastructure: Glob patterns for infrastructure paths
(merged with DEFAULT_INFRASTRUCTURE unless use_defaults is False).
infrastructure_overrides: Exact paths that are infrastructure
despite not matching any infrastructure pattern.
user_facing_overrides: Exact paths that are user-facing
despite matching an infrastructure pattern (safety override).
tags: Dict mapping tag name to list of glob patterns.
use_defaults: If True (default), merge with DEFAULT_INFRASTRUCTURE.
"""
infrastructure: list[str] = field(default_factory=list)
infrastructure_overrides: list[str] = field(default_factory=list)
user_facing_overrides: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
use_defaults: bool = True
@classmethod
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
"""Load classifier config from pyproject.toml.
Reads the ``[tool.devx.classify]`` section. If the section or
file is missing, returns a config with only DEFAULT_INFRASTRUCTURE
(everything else defaults to user-facing — safe-by-default).
"""
path = Path(pyproject_path)
if not path.exists():
return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE))
with open(path, "rb") as f: # noqa: PTH123
data: dict[str, Any] = tomllib.load(f)
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
use_defaults = classify_cfg.get("use_defaults", True)
project_infra = list(classify_cfg.get("infrastructure", []))
if use_defaults:
# Merge defaults with project-specific patterns (deduplicated)
merged = list(DEFAULT_INFRASTRUCTURE)
for p in project_infra:
if p not in merged:
merged.append(p)
infrastructure = merged
else:
infrastructure = project_infra
return cls(
infrastructure=infrastructure,
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
use_defaults=use_defaults,
)
class ChangeClassifier:
"""Classify changed files as user-facing or infrastructure.
Uses layered rules with safe-by-default semantics.
Rule evaluation order (first match wins):
1. User-facing overrides (safety — highest priority)
2. Infrastructure overrides
3. Infrastructure patterns
4. Default: user-facing (safe)
"""
def __init__(self, config: ClassifierConfig | None = None) -> None:
self.config = config or ClassifierConfig.from_pyproject()
# Pre-compile infrastructure patterns for efficiency
self._infra_patterns = list(self.config.infrastructure)
self._infra_overrides = set(self.config.infrastructure_overrides)
self._user_overrides = set(self.config.user_facing_overrides)
def classify_file(self, file_path: str) -> FileClassification:
"""Classify a single file path.
Returns a FileClassification with the decision and reason.
"""
tags = self._compute_tags(file_path)
# 1. User-facing overrides (highest priority — safety)
if file_path in self._user_overrides:
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing override (safety override)",
matched_rule="user_facing_overrides",
tags=tags,
)
# 2. Infrastructure overrides
if file_path in self._infra_overrides:
return FileClassification(
path=file_path,
is_user_facing=False,
reason="Infrastructure override (explicitly listed)",
matched_rule="infrastructure_overrides",
tags=tags,
)
# 3. Infrastructure patterns
for pattern in self._infra_patterns:
if _matches_glob(file_path, pattern):
return FileClassification(
path=file_path,
is_user_facing=False,
reason=f"Infrastructure (matches '{pattern}')",
matched_rule=f"infrastructure: {pattern}",
tags=tags,
)
# 4. Default: user-facing (safe-by-default)
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing (default — not in infrastructure patterns)",
matched_rule=None,
tags=tags,
)
def classify(self, files: list[str]) -> ClassificationResult:
"""Classify a list of changed files.
Returns a ClassificationResult with per-file details and
aggregated lists.
"""
result = ClassificationResult()
all_tags: dict[str, list[str]] = {}
for f in files:
fc = self.classify_file(f)
result.files.append(fc)
if fc.is_user_facing:
result.user_facing.append(f)
else:
result.infrastructure.append(f)
for tag in fc.tags:
all_tags.setdefault(tag, []).append(f)
result.tags = all_tags
return result
def _compute_tags(self, file_path: str) -> frozenset[str]:
"""Compute custom category tags for a file path."""
matched: set[str] = set()
for tag_name, patterns in self.config.tags.items():
for pattern in patterns:
if _matches_glob(file_path, pattern):
matched.add(tag_name)
break
return frozenset(matched)
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
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")
# ---------------------------------------------------------------------------
# Backward-compatible API (used by release.py and CI workflows)
# ---------------------------------------------------------------------------
# Singleton classifier — loaded lazily from pyproject.toml
_classifier: ChangeClassifier | None = None
def _get_classifier() -> ChangeClassifier:
"""Get or create the singleton classifier from pyproject.toml."""
global _classifier # noqa: PLW0603
if _classifier is None:
_classifier = ChangeClassifier()
return _classifier
def is_workflow_only(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is infrastructure (not user-facing).
Backward-compatible API. Prefer ``ChangeClassifier.classify_file()``
for new code.
Args:
file_path: Path relative to repo root.
patterns: Deprecated. If provided, uses simple prefix matching
against these patterns instead of the config-driven classifier.
"""
if patterns is not None:
# Legacy mode — simple prefix matching
return any(file_path.startswith(p) or file_path == p for p in patterns)
return not _get_classifier().classify_file(file_path).is_user_facing
def is_user_facing(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is user-facing (affects the released package).
Inverse of ``is_workflow_only()``.
"""
return not is_workflow_only(file_path, patterns)
def classify_changes(
files: list[str],
patterns: frozenset[str] | None = None,
) -> dict[str, list[str]]:
"""Classify changed files into user-facing and workflow-only.
Returns a dict with keys "user_facing" and "workflow_only".
"""
if patterns is not None:
# Legacy mode
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f, patterns):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
result = _get_classifier().classify(files)
return {"user_facing": result.user_facing, "workflow_only": result.infrastructure}
def has_user_facing_changes(
base: str,
head: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if any user-facing files changed between base and head.
Imported by ``devx.ci.release`` to decide whether a release is needed.
"""
files = get_changed_files(base, head)
if patterns is not None:
return any(is_user_facing(f, patterns) for f in files)
return _get_classifier().classify(files).has_user_facing
# ---------------------------------------------------------------------------
# Gitea Actions output
# ---------------------------------------------------------------------------
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")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@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",
default="all",
help="Check specific category: 'all' (default), 'user-facing', or any tag name "
"defined in [tool.devx.classify.tags] (e.g., 'ansible').",
)
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
if base is None:
base = get_latest_tag()
if not base:
if github_output:
_write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-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("user-facing-changed", "false")
for tag in available_tags:
_write_github_output(f"{tag}-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
result = classifier.classify(files)
if github_output:
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
click.echo(f"User-facing files changed: {result.has_user_facing}")
for tag in available_tags:
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
return
# --check: check a specific tag or user-facing
if check != "all":
if check == "user-facing":
checked_files = result.user_facing
has_checked = bool(checked_files)
label = "User-facing"
elif check in available_tags:
checked_files = result.tags.get(check, [])
has_checked = bool(checked_files)
label = check.capitalize()
else:
raise click.ClickException(
_(
"Unknown check category '{check}'. Available: all, user-facing{tags}",
check=check,
tags=", " + ", ".join(available_tags) if available_tags else "",
)
)
if quiet:
click.echo("true" if has_checked else "false")
return
click.echo(_("\n{label} files changed ({count}):", label=label, count=len(checked_files)))
for f in checked_files:
click.echo(f" {f}")
click.echo(
_("\nResult: {status}", status=f"{label} changes detected" if has_checked else f"No {label} changes")
)
return
has_user = result.has_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.infrastructure)))
for f in result.infrastructure:
click.echo(f" {f}")
for tag in available_tags:
tag_files = result.tags.get(tag, [])
if tag_files:
click.echo(_("\n{tag} files ({count}):", tag=tag.capitalize(), count=len(tag_files)))
for f in tag_files:
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
@@ -0,0 +1,65 @@
#!/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 -m devx.ci.detect_release_commit
"""
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()
+186
View File
@@ -0,0 +1,186 @@
#!/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 -m devx.ci.discover_runners --owner oblachno-oss --repo devx
python3 -m devx.ci.discover_runners --indices
python3 -m devx.ci.discover_runners --count
"""
from __future__ import annotations
import json
import os
import click
import requests
from devx.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. Fallbacks are logged to stderr for debugging.
"""
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)
else:
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
# 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)
else:
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
# 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)
elif r.status_code != 403: # 403 is expected without admin scope
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
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("DEVX_REPO_OWNER", "oblachno-oss")
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "devx")
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()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Distribute a list of files across N parallel runners (round-robin).
Generic file-based test distribution for CI matrix jobs. Discovers files
matching a glob pattern, sorts them for deterministic ordering, then
assigns them round-robin to *max_runners* groups. The assigned group for
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
Usage::
python3 -m devx.ci.distribute_files \\
--pattern "tests/integration/test_*.py" \\
--runner-index 1 \\
--max-runners 3 \\
--github-env --skip-if-excess
"""
from __future__ import annotations
import glob
import os
import click
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
def discover_files(pattern: str) -> list[str]:
"""Return sorted list of file paths matching *pattern*."""
return sorted(glob.glob(pattern))
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
"""Split *files* into *max_runners* balanced groups (round-robin)."""
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, f in enumerate(files):
groups[i % max_runners].append(f)
return groups
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
"""Return the subset of files assigned to *runner_index* (0-based)."""
groups = distribute(files, 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:
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("--pattern", required=True, help="Glob pattern for files to distribute.")
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index. 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(
"--github-env",
is_flag=True,
default=False,
help="Write ASSIGNED_FILES and SKIP to $GITHUB_ENV.",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def main(pattern: str, runner_index: int | None, max_runners: int, github_env: bool, skip_if_excess: bool) -> None:
files = discover_files(pattern)
if runner_index is None:
groups = distribute(files, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
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("ASSIGNED_FILES", "")
_write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners)
encoded = "\n".join(assigned)
if github_env:
_write_github_env("ASSIGNED_FILES", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
main()
+173
View File
@@ -0,0 +1,173 @@
#!/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 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import click
from devx.i18n import _
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
DOCS_DIR = REPO_ROOT / "docs"
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
# Major modules that should be documented in tech/architecture.md
REQUIRED_MODULES = [
"cli.py",
"i18n.py",
"exceptions.py",
"api_clients.py",
"config.py",
"gitea_cli.py",
]
# CI scripts that should be documented in tech/ci-cd-workflow.md
REQUIRED_SCRIPTS = [
"auto_merge.py",
"release.py",
"publish.py",
"pr_review.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."""
if not CLI_FILE.exists():
return []
content = CLI_FILE.read_text()
commands: list[str] = []
# Find all @<group>.command("name") occurrences in the CLI source
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
for match in re.finditer(r"@\w+\.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'["\']([^"\']+)["\']', 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
# Also matches subgroup prefixes like "devx ci release" or "devx tools setup"
patterns = [
rf"##.*\b{re.escape(command)}\b",
rf"`devx\s+(?:\w+\s+)?{re.escape(command)}\b",
rf"\bdevx\s+(?:\w+\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: devx {cmd}", cmd=cmd))
else:
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
missing.append(f"CLI command: devx {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()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Run integration tests with cross-runner failure detection.
Wraps ``pytest`` with the same Gitea API polling mechanism used by
``molecule_ci_guard``. If any other integration-tests matrix runner
reports failure, the current pytest subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
to the pytest invocation).
Usage::
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- test_file1.py test_file2.py
# With pytest options
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- -x -v --tb=short test_file1.py
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. "integration-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
import click
from devx.i18n import _
from devx.molecule.molecule_ci_guard import (
poll_for_other_failures,
)
POLL_INTERVAL = 10
@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
@click.option(
"--junit-output",
default=None,
help="Path for JUnit XML output (passed to pytest as --junitxml).",
)
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
"""Run pytest with cross-runner failure detection."""
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", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
owner, _sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
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()
cmd = [sys.executable, "-m", "pytest"]
if junit_output:
cmd.extend(["--junitxml", junit_output])
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
process = subprocess.Popen( # nosec B603
cmd,
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()
click.echo(_("Integration tests cancelled — another runner failed."))
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)
finally:
stop_event.set()
rc = process.returncode
if rc != 0:
click.echo(_("Integration tests failed with exit code {code}", code=rc))
else:
click.echo(_("Integration tests passed."))
sys.exit(rc)
if __name__ == "__main__": # pragma: no cover
cli()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Merge multiple JUnit XML reports into a single report.
Used by CI workflows to consolidate JUnit XML files produced by
parallel matrix runners into a single merged report for archival
and dashboard consumption.
Usage::
python3 -m devx.ci.merge_junit \\
--pattern "junit-results/runner-*.xml" \\
--output junit-merged.xml
Exit code is non-zero if any merged test suite reports failures,
making this suitable as a CI gating step after matrix jobs.
"""
from __future__ import annotations
import glob
import sys
import xml.etree.ElementTree as ET # nosec B405
import click
from devx.i18n import _
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` with zero counts.
"""
files = sorted(glob.glob(pattern))
merged = ET.Element("testsuites")
total_tests = 0
total_failures = 0
for f in files:
tree = ET.parse(f) # nosec B314
suite = tree.getroot()
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
if suite.tag == "testsuites":
for child in suite:
merged.append(child)
total_tests += int(child.get("tests", 0))
total_failures += int(child.get("failures", 0))
else:
merged.append(suite)
total_tests += int(suite.get("tests", 0))
total_failures += int(suite.get("failures", 0))
merged.set("tests", str(total_tests))
merged.set("failures", str(total_failures))
return merged, total_tests, total_failures
@click.command()
@click.option(
"--pattern",
default="junit-results/runner-*.xml",
show_default=True,
help="Glob pattern for input JUnit XML files.",
)
@click.option(
"--output",
default="junit-merged.xml",
show_default=True,
help="Output path for the merged JUnit XML file.",
)
def main(pattern: str, output: str) -> None:
merged, total_tests, total_failures = merge_files(pattern)
if total_tests == 0:
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
return
ET.indent(merged)
tree = ET.ElementTree(merged)
tree.write(output, encoding="UTF-8", xml_declaration=True)
click.echo(
_(
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
count=len(glob.glob(pattern)),
tests=total_tests,
failures=total_failures,
output=output,
)
)
if total_failures > 0:
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+154
View File
@@ -0,0 +1,154 @@
#!/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 — tea must be installed and configured.
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
--commit <commit_sha> \
--auto-login
With ``--auto-login``, the script configures the tea CLI login profile
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError
from devx.i18n import _
load_dotenv()
logger = logging.getLogger("devx")
def _configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
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("notify_failure: tea not installed — skipping login configuration.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
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"notify_failure: tea login '{login_name}' already configured.")
return
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index.
Raises TeaCLIError if tea is not installed or the command fails.
Label operations are best-effort — failures are logged but don't
prevent issue creation.
"""
tea = TeaCLI(repo=repo)
# Check if "bug" label exists (best-effort)
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 as e:
logger.warning("Could not fetch labels (best-effort): %s", e)
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
if labels:
try:
tea.add_label(repo, issue["index"], labels)
except TeaCLIError as e:
logger.warning("Could not add label to issue #%s (best-effort): %s", issue.get("index"), e)
return int(issue.get("index", 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.")
@click.option(
"--auto-login",
is_flag=True,
default=False,
help="Configure tea CLI login from REPO_TOKEN before creating the issue.",
)
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if auto_login:
_configure_tea_login()
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:
issue_id = _create_issue_via_tea(repo, title, body)
except TeaCLIError as e:
raise click.ClickException(_("Failed to create issue via tea: {error}", error=str(e))) from None
click.echo(
_(
"Created issue #{issue_id}: {title}",
issue_id=issue_id or "?",
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Update Vikunja task after a merge to master.
Usage:
VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <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 devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError
from devx.i18n import _
load_dotenv()
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 DEVX-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 DEVX-N prefix from the commit subject.
Handles both formats:
- ``DEVX-N: <message>`` (legacy, colon-separated)
- ``DEVX-N <message>`` (current, space-separated)
"""
first_line = commit_msg.split("\n")[0]
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
"""Resolve DEVX-N identifier to Vikunja numeric task ID.
Paginates through the project's tasks to handle projects with more
than 50 tasks. Raises ClickException if the task is not found.
"""
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 {task_id} in project {project_id}. "
"Every PR must have a corresponding Vikunja task.",
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 DEVX-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 DEVX-N task ID), skipping Vikunja update: {msg}",
msg=first_line,
)
)
return
# Non-infrastructure commits without DEVX-N prefix — this is a
# convention violation. Fail the post-merge job so the issue is visible.
raise click.ClickException(
_(
"No task ID ({prefix}-N) found in commit message: {msg}. "
"Every non-infrastructure commit must have a task ID.",
prefix=TASK_PREFIX,
msg=first_line,
)
)
client = VikunjaClient(VIKUNJA_API_URL, token)
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)
try:
client.post_comment(vikunja_task_id, html)
client.update_task(vikunja_task_id, done=True)
except APIError as e:
# Vikunja API failures must be visible — the task was not updated
# and needs manual intervention. Failing the CI job makes this visible.
raise click.ClickException(
_(
"Vikunja API error (HTTP {status}): {message}. "
"Task {task_id} was NOT updated. "
"The merge succeeded but the Vikunja task needs manual update.",
status=e.status,
message=e.message,
task_id=task_id,
)
) from e
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()
+571
View File
@@ -0,0 +1,571 @@
#!/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. i18n — no raw English strings in click.echo() without _() wrapper
5. Resource management — no open() without with statement, no subprocess without cleanup
6. Documentation — new CLI commands documented, new modules in architecture.md
7. Test coverage — 100% enforced by pytest-cov (checked in quality job)
8. Commit conventions — conventional commit format on branch commits
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.pr_review <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 devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL
from devx.exceptions import APIError
from devx.i18n import _
load_dotenv()
# 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/devx/cli.py"
EXECUTOR_FILE = "src/devx/executor.py"
CONFIG_FILE = "src/devx/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_i18n(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that user-facing strings are wrapped in _().
Detects ``click.echo()`` calls with raw string literals that are not
wrapped in ``_()``. Only checks ``src/`` files, not tests or scripts.
"""
# Pattern: click.echo("...") or click.echo(f"...") without _() wrapper
raw_echo_re = re.compile(r'click\.echo\s*\(\s*["\']([^"\']+)["\']')
raw_fstring_re = re.compile(r'click\.echo\s*\(\s*f["\']')
# Also check click.ClickException and raise with string
raw_exception_re = re.compile(r'click\.ClickException\s*\(\s*["\']([^"\']+)["\']')
for f in files:
path = f.get("filename", "")
if not is_python_file(path) or not path.startswith("src/"):
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:]
# Skip comments and docstrings
stripped = content.strip()
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
continue
# Check for raw strings in click.echo without _()
for regex, msg in [
(raw_echo_re, "click.echo() with raw string — wrap in _() for i18n"),
(raw_fstring_re, "click.echo() with f-string — wrap in _() for i18n"),
(raw_exception_re, "ClickException with raw string — wrap in _() for i18n"),
]:
if regex.search(content):
result.add_issue(path, current_line, msg, "warning")
if not any("i18n" in i["body"] for i in result.issues):
result.add_summary("- i18n: OK")
def check_resource_management(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for resource leaks: open() without with, subprocess without cleanup.
Detects:
- ``open()`` calls not in a ``with`` statement
- ``subprocess.Popen()`` without ``.wait()`` or ``.communicate()``
"""
# Pattern: open("...") not preceded by "with" on the same line
open_re = re.compile(r"(?<!with\s)\bopen\s*\(")
popen_re = re.compile(r"subprocess\.Popen\s*\(")
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:]
# Skip comments
if content.strip().startswith("#"):
continue
# Check for open() without with
if open_re.search(content) and "with " not in content:
result.add_issue(
path, current_line, "open() without with statement — potential resource leak", "warning"
)
# Check for Popen without communicate/wait on same line
if popen_re.search(content) and ".communicate" not in content and ".wait" not in content:
result.add_issue(
path,
current_line,
"subprocess.Popen() without immediate .communicate() or .wait() — ensure cleanup",
"warning",
)
if not any("resource" in i["body"].lower() for i in result.issues):
result.add_summary("- Resource management: 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 devx.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_i18n(files, result)
check_resource_management(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("**Auto-merge:** If all CI checks pass, this PR will be merged automatically.")
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()
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Build package, optionally publish to PyPI or Gitea PyPI, and create Gitea release.
Uses git-cliff to generate the release notes from conventional commits.
Uses the ``tea`` Gitea CLI for release creation.
Publishing destinations (checked in order):
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the
``REPO_TOKEN`` as both username and password.
2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow.
3. **Skip** — if neither is configured, only the Gitea release is created.
Usage:
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
REPO_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
"""
import os
import shutil
import subprocess # nosec B404
import sys
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError
from devx.i18n import _
load_dotenv()
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."))
def publish_to_gitea_registry(registry_url: str, token: str) -> None:
"""Publish built packages to a Gitea PyPI registry using twine.
Uses the token as both username and password, which is the standard
Gitea package authentication method.
"""
result = subprocess.run( # nosec B603
[
sys.executable,
"-m",
"twine",
"upload",
"dist/*",
"--repository-url",
registry_url,
"-u",
token,
"-p",
token,
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=result.stderr.strip(),
)
)
click.echo(_("Published to Gitea PyPI registry."))
def _default_gitea_registry_url() -> str:
"""Derive a Gitea PyPI registry URL from GITEA_API_URL.
Converts e.g. ``https://git.example.com/api/v1`` to
``https://git.example.com/api/packages/<owner>/pypi``.
The owner is read from ``DEVX_REPO_OWNER``.
"""
base = GITEA_API_URL.rstrip("/")
# Strip /api/v1 or /api suffix to get the base URL
if base.endswith("/api/v1"):
base = base[: -len("/api/v1")]
elif base.endswith("/api"):
base = base[: -len("/api")]
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
return f"{base}/api/packages/{owner}/pypi"
@click.command()
@click.argument("tag")
@click.argument("repo")
@click.option(
"--registry-url",
default=None,
help="Gitea PyPI registry URL. Defaults to DEVX_PYPI_REGISTRY_URL env var "
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
"instead of standard PyPI (unless PYPI_TOKEN is also set).",
)
@click.option(
"--skip-build",
is_flag=True,
default=False,
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> 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", "")
# Resolve registry URL: CLI flag > env var > derived from GITEA_API_URL
if registry_url is None:
registry_url = os.environ.get("DEVX_PYPI_REGISTRY_URL", "")
if not registry_url:
registry_url = _default_gitea_registry_url()
if not skip_build:
build_package()
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
)
else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
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()
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Generate badge SVG files and push them to the ``badges`` branch.
Also updates README.md and docs/index.md on master with cache-busting
``raw/commit/<sha>/badge.svg`` URLs so that browsers always fetch the
latest badge version (Gitea caches ``raw/branch/`` URLs for 6 hours).
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 -m devx.ci.push_badges
"""
from __future__ import annotations
import contextlib
import re
import subprocess # nosec B404
import sys
import time
from pathlib import Path
from typing import Any
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
# Badge filenames that get pushed to the badges branch
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
# Files that contain badge URLs and need to be updated
FILES_WITH_BADGE_URLS = ["README.md", "docs/index.md"]
# Pattern to match raw/branch/badges/<name>.svg URLs
_BADGE_URL_RE = re.compile(r"(https://[^/]+/[^/]+/[^/]+/raw/)(?:branch/badges|commit/[0-9a-f]{40})/([a-z_]+\.svg)")
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 _run_capture(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Run a command and capture stdout."""
return subprocess.run(cmd, check=True, text=True, capture_output=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 devx.tools.generate_badges."""
_run([sys.executable, "-m", "devx.tools.generate_badges", "--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) -> str:
"""Push generated badges to the orphan ``badges`` branch.
Returns the commit SHA of the pushed 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")
# Get the commit SHA of the badges branch
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
sha = result.stdout.strip()
click.echo(f"Badges commit SHA: {sha}")
return sha
def update_badge_urls(content: str, badges_sha: str) -> str:
"""Replace raw/branch/badges/<name>.svg URLs with raw/commit/<sha>/<name>.svg.
This bypasses Gitea's 6-hour cache on raw/branch/ URLs by using a
URL that changes each time the badges branch is updated.
"""
return _BADGE_URL_RE.sub(
lambda m: f"{m.group(1)}commit/{badges_sha}/{m.group(2)}",
content,
)
def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) -> None:
"""Update README.md and docs/index.md with cache-busting badge URLs.
Switches back to master, replaces ``raw/branch/badges/`` URLs with
``raw/commit/<sha>/`` URLs, commits and pushes.
"""
root = repo_root or REPO_ROOT
# Switch back to master
_run(["git", "checkout", "master"]) # nosec B607
_run(["git", "fetch", "origin", "master"]) # nosec B607
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
updated_any = False
for filename in FILES_WITH_BADGE_URLS:
filepath = root / filename
if not filepath.exists():
continue
content = filepath.read_text()
new_content = update_badge_urls(content, badges_sha)
if new_content != content:
filepath.write_text(new_content)
click.echo(f"Updated badge URLs in {filename}")
updated_any = True
if not updated_any:
click.echo("No badge URLs found to update — README already up to date")
return
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
_run(
[
"git",
"commit",
"--no-verify",
"-m",
f"chore: update badge URLs to commit {badges_sha[:8]} [skip ci]",
]
) # nosec B607
_run(["git", "push", "origin", "master"]) # nosec B607
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
@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.")
@click.option(
"--no-readme-update",
is_flag=True,
default=False,
help="Skip updating README with cache-busting URLs (for local testing).",
)
@click.option(
"--retries",
default=1,
type=int,
help="Number of attempts on git push failures (default: 1, no retry). "
"Between attempts, fetches latest master and waits 10s.",
)
def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) -> None:
"""Generate badges and push them to the badges branch."""
last_error: Exception | None = None
for attempt in range(1, retries + 1):
try:
fetch_latest_master(branch)
generate_badges(output_dir)
badges_sha = push_to_badges_branch(output_dir)
if not no_readme_update:
update_readme_with_badge_sha(badges_sha)
return
except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc
if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch)
else:
click.echo(f"Badge push failed after {retries} attempts: {exc}")
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
if __name__ == "__main__": # pragma: no cover
main()
+721
View File
@@ -0,0 +1,721 @@
#!/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.
**Tag consistency**: Before releasing, the script fetches remote tags and
verifies all existing tags point to commits whose message matches the tag
version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.ci._shared import get_latest_tag
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _
load_dotenv()
INIT_FILE = os.getenv("DEVX_VERSION_FILE", "src/devx/__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 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_tag_commit(tag: str) -> str:
"""Get the commit hash a tag points to."""
result = run_cmd(["git", "rev-list", "-n1", tag], check=False)
return result.stdout.strip()
def get_head_commit() -> str:
"""Get the current HEAD commit hash."""
result = run_cmd(["git", "rev-parse", "HEAD"], check=False)
return result.stdout.strip()
def fetch_tags() -> None:
"""Fetch tags from remote to ensure local tag state is current.
This is critical in CI environments where a fresh checkout may not
include tags from previous runs. Without this, the script may
create duplicate release commits because ``tag_exists`` returns False
for a tag that exists on the remote but wasn't fetched.
"""
result = run_cmd(["git", "fetch", "--tags", "origin"], check=False)
if result.returncode != 0:
# Don't fail hard — maybe there's no remote (local-only repo)
click.echo(_("Warning: could not fetch tags from origin."))
def get_all_tags() -> list[str]:
"""Get all git tags sorted by version (newest first)."""
result = run_cmd(["git", "tag", "-l", "--sort=-v:refname"], check=False)
if result.returncode != 0:
return []
return [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
def get_commit_version(commit: str) -> str | None:
"""Extract version from a release commit message.
Returns the version string (e.g., '0.4.4') or None if the commit
is not a release commit.
"""
result = run_cmd(["git", "log", "-1", "--pretty=%s", commit], check=False)
match = re.match(r"^release: v(\d+\.\d+\.\d+)", result.stdout.strip())
return match.group(1) if match else None
def verify_tag_consistency() -> list[str]:
"""Verify all tags point to commits with matching version in message.
Returns a list of error messages for inconsistent tags.
An empty list means all tags are consistent.
The first release (v0.1.0 or earliest tag) is exempt — initial releases
often don't have a "release:" commit message (e.g., the initial commit
serves as the first release).
"""
errors: list[str] = []
tags = get_all_tags()
# Sort oldest first to identify the first tag
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
first_tag = sorted_tags[0] if sorted_tags else None
for tag in tags:
tag_version = tag.lstrip("v")
commit_version = get_commit_version(tag)
if commit_version is None:
# First tag is allowed to point to a non-release commit (initial release)
if tag == first_tag:
continue
errors.append(
f" {tag} → points to non-release commit (expected 'release: v{tag_version}', got non-release commit)"
)
elif commit_version != tag_version:
errors.append(f" {tag} → commit says 'release: v{commit_version}' (expected 'release: v{tag_version}')")
return errors
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
version = version.lstrip("v")
# Validate semver format
if not re.match(r"^\d+\.\d+\.\d+$", version):
raise click.ClickException(
_("git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", version=version)
)
return version
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, excluding release commits
# (release commits themselves are not "unreleased changes" — they ARE
# the release). This prevents duplicate release commits when the
# script runs multiple times.
result = run_cmd(
["git", "log", f"{latest}..HEAD", "--oneline", "--no-merges", "--invert-grep", "--grep=^release: v"],
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 ``DEVX-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:
# Print test output for debugging (capture=True hides it otherwise)
if tests.stdout:
click.echo(tests.stdout)
if tests.stderr:
click.echo(tests.stderr, err=True)
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.
Raises an error if the tag exists but points to a different commit than HEAD.
"""
tag = f"v{new_version}"
if tag_exists(tag):
# Verify the tag points to HEAD — if it points elsewhere, that's
# a consistency error, not a skip condition.
tag_commit = get_tag_commit(tag)
head_commit = get_head_commit()
if tag_commit != head_commit:
raise click.ClickException(
_(
"Tag {tag} already exists but points to {tag_commit} "
"(expected HEAD {head_commit}). "
"This indicates a tag/commit misalignment. "
"Run 'python3 -m devx.ci.release --verify' for details.",
tag=tag,
tag_commit=tag_commit[:7],
head_commit=head_commit[:7],
)
)
click.echo(_("Tag {tag} already exists and points to HEAD. 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
# ---------------------------------------------------------------------------
# Verification mode
# ---------------------------------------------------------------------------
def get_init_version() -> str | None:
"""Read __version__ from the version file."""
try:
with open(INIT_FILE) as f:
content = f.read()
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
return match.group(1) if match else None
except FileNotFoundError:
return None
def get_changelog_versions() -> list[str]:
"""Extract version numbers from CHANGELOG.md headers, in order."""
try:
with open(CHANGELOG_FILE) as f:
content = f.read()
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
except FileNotFoundError:
return []
def verify_alignment() -> int:
"""Verify tag/version/changelog alignment. Returns exit code (0=ok, 1=issues)."""
click.echo(_("=== Release Alignment Verification ===\n"))
has_issues = False
# 1. Check __version__ matches latest tag
init_version = get_init_version()
latest_tag = get_latest_tag()
latest_tag_version = latest_tag.lstrip("v") if latest_tag else None
click.echo(_("Version file: {file}", file=INIT_FILE))
if init_version:
click.echo(f' __version__ = "{init_version}"')
else:
click.echo(" __version__ = NOT FOUND")
has_issues = True
click.echo(_("\nLatest tag: {tag}", tag=latest_tag or "(none)"))
if latest_tag_version and init_version:
if latest_tag_version == init_version:
click.echo(f" ✓ Tag version matches __version__ ({init_version})")
else:
click.echo(f" ✗ MISMATCH: tag={latest_tag_version}, __version__={init_version}")
has_issues = True
# 2. Check all tags point to commits with matching version
click.echo(_("\nTag → Commit alignment:"))
tag_errors = verify_tag_consistency()
all_tags = get_all_tags()
if not all_tags:
click.echo(" (no tags)")
elif not tag_errors:
click.echo(f" ✓ All {len(all_tags)} tags point to matching release commits")
else:
has_issues = True
for err in tag_errors:
click.echo(f"{err}")
# 3. Check CHANGELOG versions are in descending order
click.echo(_("\nCHANGELOG version ordering:"))
changelog_versions = get_changelog_versions()
if not changelog_versions:
click.echo(" (no versions in CHANGELOG)")
else:
# Check for duplicates
seen: set[str] = set()
duplicates: list[str] = []
for v in changelog_versions:
if v in seen:
duplicates.append(v)
seen.add(v)
# Check ordering (should be descending)
is_ordered = all(changelog_versions[i] >= changelog_versions[i + 1] for i in range(len(changelog_versions) - 1))
if duplicates:
has_issues = True
click.echo(f" ✗ Duplicate entries: {', '.join(duplicates)}")
elif not is_ordered:
has_issues = True
click.echo(f" ✗ Versions not in descending order: {changelog_versions}")
else:
click.echo(f"{len(changelog_versions)} versions, all in descending order")
# Check latest CHANGELOG version matches latest tag.
# The CHANGELOG may have one unreleased section ahead of the latest tag
# (e.g., CHANGELOG has 0.6.4 but latest tag is v0.6.3 — 0.6.4 is unreleased).
if changelog_versions and latest_tag_version:
if changelog_versions[0] == latest_tag_version:
click.echo(f" ✓ Latest CHANGELOG version matches latest tag ({latest_tag_version})")
elif latest_tag_version in changelog_versions:
tag_idx = changelog_versions.index(latest_tag_version)
# Latest tag should be at index 0 or 1 (0 = released, 1 = unreleased ahead)
if tag_idx == 1:
click.echo(
f" ✓ Latest CHANGELOG version ({changelog_versions[0]}) is unreleased, "
f"latest tag is {latest_tag_version}"
)
else:
click.echo(
f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, "
f"tag={latest_tag_version} (tag is at position {tag_idx})"
)
has_issues = True
else:
click.echo(f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, tag={latest_tag_version}")
has_issues = True
# 4. Check for untagged release commits.
# Distinguish between:
# - Truly untagged: no tag exists for that version (needs a tag)
# - Duplicates: a tag for that version exists but on a different commit
# (historical artifact from buggy release script — informational, not an error)
click.echo(_("\nUntagged release commits:"))
result = run_cmd(
["git", "log", "--all", "--format=%h %s", "--grep=^release: v"],
check=False,
)
if result.returncode == 0 and result.stdout.strip():
all_release_commits = result.stdout.strip().split("\n")
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
truly_untagged: list[str] = []
duplicates: list[str] = []
for line in all_release_commits:
short_hash = line.split()[0]
tags_at = run_cmd(["git", "tag", "--points-at", short_hash], check=False)
if not tags_at.stdout.strip():
# Check if a tag for this version exists elsewhere
match = re.search(r"release: v(\d+\.\d+\.\d+)", line)
if match and match.group(1) in all_tags_set:
duplicates.append(line)
else:
truly_untagged.append(line)
if truly_untagged:
has_issues = True
click.echo(f"{len(truly_untagged)} untagged release commits (no tag for version):")
for c in truly_untagged[:10]:
click.echo(f" {c}")
if len(truly_untagged) > 10:
click.echo(f" ... and {len(truly_untagged) - 10} more")
else:
click.echo(" ✓ All release commits have tags")
if duplicates:
click.echo(f" {len(duplicates)} duplicate release commits (tag exists on different commit):")
for c in duplicates[:5]:
click.echo(f" {c}")
if len(duplicates) > 5:
click.echo(f" ... and {len(duplicates) - 5} more")
else:
click.echo(" (no release commits found)")
# Summary
click.echo(_("\n=== Summary ==="))
if has_issues:
click.echo("✗ Issues found — see above for details.")
return 1
click.echo("✓ All checks passed — tags, versions, and changelog are aligned.")
return 0
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
@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).",
)
@click.option(
"--verify",
is_flag=True,
default=False,
help="Verify tag/version/changelog alignment and exit (no changes made).",
)
def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
"""Automated release: calculate next version, update files, tag, and push.
Use --verify to check tag/version/changelog alignment without making changes.
"""
if verify:
sys.exit(verify_alignment())
# 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,
)
)
# Fetch tags from remote to ensure local tag state is current.
# This is critical in CI where a fresh checkout may not include tags
# from previous runs. Without this, tag_exists() returns False for
# tags that exist on the remote, leading to duplicate release commits.
if not dry_run:
fetch_tags()
# Pre-flight: verify existing tags are consistent. If any tag points
# to a commit with a mismatched version, abort before creating more
# inconsistencies.
tag_errors = verify_tag_consistency()
if tag_errors:
click.echo(_("ERROR: Tag consistency check failed. Existing tags are misaligned:"))
for err in tag_errors:
click.echo(err)
click.echo(
_(
"\nFix the misaligned tags before creating new releases. "
"Run 'python3 -m devx.ci.release --verify' for a full report."
)
)
raise click.ClickException(_("Tag consistency check failed."))
# Release lock: if HEAD is already a release commit, check if the tag
# exists AND points to HEAD. If the tag is missing (e.g., tag push
# failed in a previous run), create and push it. If the tag exists
# but points elsewhere, that's an error.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg)
if release_match:
release_version = release_match.group(1)
release_tag = f"v{release_version}"
if tag_exists(release_tag):
tag_commit = get_tag_commit(release_tag)
head_commit = get_head_commit()
if tag_commit != head_commit:
raise click.ClickException(
_(
"HEAD is a release commit for v{version} but tag {tag} "
"points to a different commit ({tag_commit} vs HEAD {head_commit}). "
"This indicates a tag/commit misalignment.",
version=release_version,
tag=release_tag,
tag_commit=tag_commit[:7],
head_commit=head_commit[:7],
)
)
click.echo(
_(
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
msg=head_msg,
tag=release_tag,
)
)
return
# Tag is missing — recover by creating and pushing it
click.echo(
_(
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
msg=head_msg,
tag=release_tag,
)
)
changelog = get_changelog(release_version)
create_and_push_tag(release_version, changelog, dry_run)
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:
raise click.ClickException(
_(
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
version=new_version,
)
)
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()
+328
View File
@@ -0,0 +1,328 @@
#!/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 -m devx.ci.sync_wiki [--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 devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL
from devx.exceptions import APIError
from devx.i18n import _
load_dotenv()
# DOCS_DIR is the repo's docs/ directory. When devx is installed as a
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
# __file__-relative path would point inside the venv, not the repo.
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
# (relative to the current working directory, which is the repo root
# in CI and local development).
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
MAPPING_FILE = DOCS_DIR / "mapping.json"
def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json.
Validates that the mapping is a dict of string-to-string pairs.
"""
with open(MAPPING_FILE) as f:
data = json.load(f)
if not isinstance(data, dict):
raise click.ClickException(
_("mapping.json must be a dict of file-path -> page-title, got {type}", type=type(data).__name__)
)
for k, v in data.items():
if not isinstance(k, str) or not isinstance(v, str):
raise click.ClickException(_("mapping.json keys and values must be strings, got {k}={v}", k=k, v=v))
return data
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("DEVX_REPO_OWNER", "oblachno-oss")
repo_name = os.environ.get("DEVX_REPO_NAME", "devx")
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:
raise click.ClickException(
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
) from None
if not content.strip():
raise click.ClickException(
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
) from None
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()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Validate commit messages for devx.
Rules:
- On feature branches: conventional commits ONLY, must NOT include <PREFIX>-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'DEVX-24: fix: resolve timeout'.
The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment
variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set
their own prefix (e.g., ``GRM``) so the validator enforces the correct
task ID format for each project.
"""
import re
import subprocess # nosec B404
import click
from devx.config import CONVENTIONAL_RE, TASK_PREFIX
from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\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")
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
def main(commit_msg_file: str, branch: str | None) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
if branch is None:
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: {prefix}-N: <conventional commit message>\n"
" Got: {subject}",
prefix=TASK_PREFIX,
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: {prefix}-N: <type>: <description>\n"
" Got: {subject}",
prefix=TASK_PREFIX,
subject=subject,
)
)
return
if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI.",
prefix=TASK_PREFIX,
)
)
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()
+263
View File
@@ -0,0 +1,263 @@
"""CLI entry point for devx — routes to subcommands.
Usage::
devx ci <command> [args] # CI/CD automation scripts
devx tools <command> [args] # Development tools
devx molecule <command> [args] # Molecule testing tools (optional)
"""
from __future__ import annotations
import sys
import click
from devx.i18n import _
def _run_module(module_path: str, args: list[str]) -> None:
"""Run a devx module's main() with the given args."""
import importlib
module = importlib.import_module(module_path)
# Set sys.argv so click sees the right arguments
old_argv = sys.argv
sys.argv = [module_path] + args
try:
main_fn = getattr(module, "main", None)
if main_fn is None:
raise click.ClickException(_("Module {mod} has no main() function", mod=module_path))
if callable(main_fn):
main_fn(args=args, standalone_mode=False) # type: ignore[arg-type]
else:
# It's a click command object
main_fn.main(args=args, prog_name=module_path, standalone_mode=False) # type: ignore[attr-defined]
except click.exceptions.Abort:
sys.exit(1)
except click.ClickException as e:
e.show()
sys.exit(e.exit_code)
finally:
sys.argv = old_argv
@click.group()
@click.version_option()
def cli() -> None:
"""devx — reusable development and CI/CD tools."""
@cli.group()
def ci() -> None:
"""CI/CD automation commands."""
@ci.command("auto-merge")
@click.argument("args", nargs=-1)
def ci_auto_merge(args: tuple[str, ...]) -> None:
"""Auto-merge PR when all CI checks pass."""
_run_module("devx.ci.auto_merge", list(args))
@ci.command("check-translations")
@click.argument("args", nargs=-1)
def ci_check_translations(args: tuple[str, ...]) -> None:
"""Check translation files for gaps and dead keys."""
_run_module("devx.ci.check_translations", list(args))
@ci.command("classify-changes")
@click.argument("args", nargs=-1)
def ci_classify_changes(args: tuple[str, ...]) -> None:
"""Classify git changes as user-facing or workflow-only."""
_run_module("devx.ci.classify_changes", list(args))
@ci.command("detect-release-commit")
@click.argument("args", nargs=-1)
def ci_detect_release_commit(args: tuple[str, ...]) -> None:
"""Detect whether the latest git commit is a release commit."""
_run_module("devx.ci.detect_release_commit", list(args))
@ci.command("discover-runners")
@click.argument("args", nargs=-1)
def ci_discover_runners(args: tuple[str, ...]) -> None:
"""Discover available Gitea Actions runners."""
_run_module("devx.ci.discover_runners", list(args))
@ci.command("doc-coverage")
@click.argument("args", nargs=-1)
def ci_doc_coverage(args: tuple[str, ...]) -> None:
"""Check documentation coverage for CLI commands and modules."""
_run_module("devx.ci.doc_coverage", list(args))
@ci.command("notify-failure")
@click.argument("args", nargs=-1)
def ci_notify_failure(args: tuple[str, ...]) -> None:
"""Create a Gitea issue when a CI workflow fails."""
_run_module("devx.ci.notify_failure", list(args))
@ci.command("post-merge")
@click.argument("args", nargs=-1)
def ci_post_merge(args: tuple[str, ...]) -> None:
"""Update Vikunja task after a merge to master."""
_run_module("devx.ci.post_merge", list(args))
@ci.command("pr-review")
@click.argument("args", nargs=-1)
def ci_pr_review(args: tuple[str, ...]) -> None:
"""Run automated PR review."""
_run_module("devx.ci.pr_review", list(args))
@ci.command("publish")
@click.argument("args", nargs=-1)
def ci_publish(args: tuple[str, ...]) -> None:
"""Build package, publish to registry, and create Gitea release."""
_run_module("devx.ci.publish", list(args))
@ci.command("push-badges")
@click.argument("args", nargs=-1)
def ci_push_badges(args: tuple[str, ...]) -> None:
"""Generate badge SVG files and push to the badges branch."""
_run_module("devx.ci.push_badges", list(args))
@ci.command("release")
@click.argument("args", nargs=-1)
def ci_release(args: tuple[str, ...]) -> None:
"""Automated release: calculate next version, update files, tag, push."""
_run_module("devx.ci.release", list(args))
@ci.command("sync-wiki")
@click.argument("args", nargs=-1)
def ci_sync_wiki(args: tuple[str, ...]) -> None:
"""Sync documentation from docs/ to the Gitea wiki."""
_run_module("devx.ci.sync_wiki", list(args))
@ci.command("validate-commit-msg")
@click.argument("args", nargs=-1)
def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
"""Validate commit messages for conventional commit format."""
_run_module("devx.ci.validate_commit_msg", list(args))
@ci.command("distribute-files")
@click.argument("args", nargs=-1)
def ci_distribute_files(args: tuple[str, ...]) -> None:
"""Distribute files across parallel runners (round-robin)."""
_run_module("devx.ci.distribute_files", list(args))
@ci.command("merge-junit")
@click.argument("args", nargs=-1)
def ci_merge_junit(args: tuple[str, ...]) -> None:
"""Merge multiple JUnit XML reports into a single report."""
_run_module("devx.ci.merge_junit", list(args))
@ci.command("integration-guard")
@click.argument("args", nargs=-1)
def ci_integration_guard(args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection and JUnit output."""
_run_module("devx.ci.integration_guard", list(args))
@cli.group()
def tools() -> None:
"""Development tool commands."""
@tools.command("check-test-speed")
@click.argument("args", nargs=-1)
def tools_check_test_speed(args: tuple[str, ...]) -> None:
"""Run unit tests and enforce a maximum execution-time budget."""
_run_module("devx.tools.check_test_speed", list(args))
@tools.command("configure-repo")
@click.argument("args", nargs=-1)
def tools_configure_repo(args: tuple[str, ...]) -> None:
"""Configure repository: branch protection + labels via Gitea API."""
_run_module("devx.tools.configure_repo", list(args))
@tools.command("generate-badges")
@click.argument("args", nargs=-1)
def tools_generate_badges(args: tuple[str, ...]) -> None:
"""Generate self-contained SVG badge files from project metrics."""
_run_module("devx.tools.generate_badges", list(args))
@tools.command("generate-cliff-config")
@click.argument("args", nargs=-1)
def tools_generate_cliff_config(args: tuple[str, ...]) -> None:
"""Generate a cliff.toml configuration file for the project."""
_run_module("devx.tools.generate_cliff_config", list(args))
@tools.command("install-checkmake")
@click.argument("args", nargs=-1)
def tools_install_checkmake(args: tuple[str, ...]) -> None:
"""Install checkmake if not already present."""
_run_module("devx.tools.install_checkmake", list(args))
@tools.command("install-tools")
@click.argument("args", nargs=-1)
def tools_install_tools(args: tuple[str, ...]) -> None:
"""Install CI/CD development tools (actionlint, git-cliff, tea, etc.)."""
_run_module("devx.tools.install_tools", list(args))
@tools.command("setup")
@click.argument("args", nargs=-1)
def tools_setup(args: tuple[str, ...]) -> None:
"""Project setup: install Python deps and pre-commit hooks."""
_run_module("devx.tools.setup", list(args))
@cli.group()
def molecule() -> None:
"""Molecule testing commands (requires devx[molecule])."""
@molecule.command("distribute")
@click.argument("args", nargs=-1)
def molecule_distribute(args: tuple[str, ...]) -> None:
"""Distribute molecule test pairs across parallel runners."""
_run_module("devx.molecule.distribute_molecule", list(args))
@molecule.command("discover-runners")
@click.argument("args", nargs=-1)
def molecule_discover_runners(args: tuple[str, ...]) -> None:
"""Discover available Gitea Actions runners for molecule tests."""
_run_module("devx.molecule.discover_runners", list(args))
@molecule.command("guard")
@click.argument("args", nargs=-1)
def molecule_guard(args: tuple[str, ...]) -> None:
"""Run molecule tests sequentially with CI failure polling."""
_run_module("devx.molecule.molecule_ci_guard", list(args))
@molecule.command("all")
@click.argument("args", nargs=-1)
def molecule_all(args: tuple[str, ...]) -> None:
"""Run all molecule scenarios on all supported OS platforms."""
_run_module("devx.molecule.molecule_all", list(args))
if __name__ == "__main__": # pragma: no cover
cli()
+36
View File
@@ -0,0 +1,36 @@
"""Shared configuration constants for devx scripts and API clients.
All defaults can be overridden via environment variables with the ``DEVX_``
prefix. Projects consuming devx can set these in their ``.env`` files.
"""
from __future__ import annotations
import os
import re
# API endpoints — override via env vars for different Gitea/Vikunja instances
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
# Organization defaults
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
# Vikunja project ID — each project uses a different Vikunja project
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
# HTTP client defaults
DEFAULT_TIMEOUT = 30
DEFAULT_PER_PAGE = 50
# Retry configuration for transient errors (429, 5xx, connection errors)
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
# Conventional commit regex — used by validate_commit_msg.py
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
+16
View File
@@ -0,0 +1,16 @@
"""Custom exceptions for devx tools and API clients."""
class DevxError(Exception):
"""Base exception for all devx errors."""
pass
class APIError(DevxError):
"""Raised when a REST API call returns an HTTP error."""
def __init__(self, status: int, message: str) -> None:
self.status = status
self.message = message
super().__init__(f"HTTP {status}: {message}")
+323
View File
@@ -0,0 +1,323 @@
#!/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 devx.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, "--description", 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)
+51
View File
@@ -0,0 +1,51 @@
"""Simple i18n for devx scripts and tools.
Set DEVX_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
JSON file with additional keys. Keys from the project's file are merged
on top of devx's built-in translations.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
# Load built-in translations
_BUILTIN_TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
)
def _load_project_translations() -> dict[str, dict[str, str]]:
"""Load project-specific translations from DEVX_TRANSLATIONS_PATH if set."""
path = os.getenv("DEVX_TRANSLATIONS_PATH")
if not path:
return {}
p = Path(path)
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
# Merge project translations on top of built-in
TRANSLATIONS: dict[str, dict[str, str]] = {**_BUILTIN_TRANSLATIONS, **_load_project_translations()}
def _(key: str, **kwargs: object) -> str:
"""Return a translated string for the given key.
Translation is opt-in via the ``DEVX_LANG`` environment variable.
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("DEVX_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
View File
+180
View File
@@ -0,0 +1,180 @@
#!/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 -m devx.molecule.discover_runners --owner oblachno-oss --repo grm
python3 -m devx.molecule.discover_runners --indices
python3 -m devx.molecule.discover_runners --count
"""
from __future__ import annotations
import json
import os
import click
import requests
from devx.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("DEVX_REPO_OWNER", "oblachno-oss")
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "devx")
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()
+340
View File
@@ -0,0 +1,340 @@
#!/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 -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
python3 -m devx.molecule.distribute_molecule --list
# prints all scenarios, one per line
python3 -m devx.molecule.distribute_molecule --list-platforms
# prints all platforms, one per line
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import click
from devx.i18n import _
from devx.molecule.platforms import PLATFORMS, load_platforms
DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
DEFAULT_ROLES_ROOT = Path("ansible/roles")
@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."""
cmd = self.platform["command"].replace(" ", "__SPACE__")
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
@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].replace("__SPACE__", " ")},
)
@dataclass(frozen=True)
class MultiRoleTestPair:
"""A (role, scenario, platform) combination for multi-role projects."""
role: str
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``."""
cmd = self.platform["command"].replace(" ", "__SPACE__")
return f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
@staticmethod
def decode(encoded: str) -> MultiRoleTestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return MultiRoleTestPair(
role=parts[0],
scenario=parts[1],
platform={"name": parts[2], "image": parts[3], "command": parts[4].replace("__SPACE__", " ")},
)
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 discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]:
"""Discover (role, scenario) pairs across all roles under *roles_root*.
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
``common`` and directories starting with ``_``. Returns a sorted list of
``(role_name, scenario_name)`` tuples.
"""
if roles_root is None:
roles_root = DEFAULT_ROLES_ROOT
if not roles_root.is_dir():
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
pairs: list[tuple[str, str]] = []
for role_dir in sorted(roles_root.iterdir()):
if not role_dir.is_dir():
continue
mol_dir = role_dir / "molecule"
if not mol_dir.is_dir():
continue
for scenario_dir in mol_dir.iterdir():
if not scenario_dir.is_dir():
continue
if scenario_dir.name.startswith("_") or scenario_dir.name == "common":
continue
pairs.append((role_dir.name, scenario_dir.name))
return pairs
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 build_multi_role_pairs(
role_scenarios: list[tuple[str, str]],
platforms: list[dict[str, str]] | None = None,
) -> list[MultiRoleTestPair]:
"""Build the full cross-product of (role, scenario) pairs and platforms."""
if platforms is None:
platforms = PLATFORMS
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
def multi_role_pairs_for_runner(
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
) -> list[MultiRoleTestPair]:
"""Return the subset of multi-role pairs assigned to *runner_index* (0-based)."""
groups = distribute_multi_role(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 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.",
)
@click.option(
"--molecule-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Custom molecule directory (single-role mode). Default: ansible/roles/gitea-runner/molecule.",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Roles directory for multi-role discovery (scans */molecule/*/). "
"Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).",
)
@click.option(
"--platforms-file",
type=click.Path(exists=True, file_okay=True, path_type=Path),
default=None,
help="JSON file with custom platform list (each entry: name, image, command). "
"Overrides the default platform matrix. Useful for projects with custom test images.",
)
def cli(
runner_index: int | None,
max_runners: int,
list_all: bool,
list_platforms: bool,
github_env: bool,
skip_if_excess: bool,
molecule_root: Path | None,
roles_root: Path | None,
platforms_file: Path | None,
) -> None:
platforms = load_platforms(platforms_file)
# Multi-role mode: discover (role, scenario) pairs across all roles
if roles_root is not None:
role_scenarios = discover_multi_role_scenarios(roles_root)
if list_all:
for role, scenario in role_scenarios:
click.echo(f"{role}|{scenario}")
return
if list_platforms:
for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
pairs_mr = build_multi_role_pairs(role_scenarios, platforms)
if runner_index is None:
groups = distribute_multi_role(pairs_mr, 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
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
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
zero_based = runner_index - 1
assigned = multi_role_pairs_for_runner(pairs_mr, 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)
return
# Single-role mode (default or --molecule-root)
scenarios = discover_scenarios(molecule_root)
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, platforms)
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
# Validate runner index is in range
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
# 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()
+92
View File
@@ -0,0 +1,92 @@
#!/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 -m devx.molecule.molecule_all
python3 -m devx.molecule.molecule_all --bin .venv/bin
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import sys
from pathlib import Path
import click
from devx.molecule.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
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""Run molecule tests sequentially while polling Gitea for other runner failures.
Each pair is encoded as one of:
- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command``
- **Multi-role (5-part):** ``role|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.
JUnit XML is generated when ``--junit-output`` is provided, recording each
pair as a testcase with pass/fail status and elapsed time.
Usage::
# Single-role (grm-style)
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
# With JUnit output
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml 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
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path
import click
import requests
from devx.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 parse_pair(pair: str) -> tuple[str, str, str, str, str]:
"""Parse a pair string into (role, scenario, platform_name, platform_image, platform_command).
Supports both 4-part (single-role) and 5-part (multi-role) formats.
For 4-part pairs, role is empty (caller uses default role dir).
Spaces in the command field are encoded as ``__SPACE__`` to survive
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
"""
parts = pair.split("|")
if len(parts) == 4:
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
if len(parts) == 5:
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair."""
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
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"
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
# from previous CI runs (causes "Instances missing" errors).
if "MOLECULE_HOME" not in env:
import tempfile
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
return env
def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path:
"""Resolve the working directory for a molecule pair.
For multi-role pairs (role non-empty), uses ``roles_root/role``.
For single-role pairs, uses ``repo_root/ansible/roles/gitea-runner``.
"""
if role:
if roles_root is None:
roles_root = repo_root / "ansible" / "roles"
return roles_root / role
return repo_root / "ansible" / "roles" / "gitea-runner"
def write_junit_report(
output_path: str,
testcases: list[dict],
runner_index: int,
) -> None:
"""Write a JUnit XML report from collected test case results.
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
"""
suite = ET.Element(
"testsuite",
name=f"molecule-runner-{runner_index}",
tests=str(len(testcases)),
failures=str(sum(1 for tc in testcases if not tc["passed"])),
)
for tc in testcases:
classname = tc["role"] if tc["role"] else "molecule"
elem = ET.SubElement(
suite,
"testcase",
classname=classname,
name=tc["scenario"],
time=f"{tc['time']:.1f}",
)
if not tc["passed"]:
fail = ET.SubElement(elem, "failure")
fail.text = tc.get("error") or "molecule test failed"
tree = ET.ElementTree(suite)
ET.indent(tree)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
@click.command()
@click.argument("pairs", nargs=-1, required=True)
@click.option(
"--junit-output",
default=None,
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
)
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> 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/devx")
owner, _sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "devx"
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.parent
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()
testcases: list[dict] = []
try:
for pair in pairs:
if failed_event.is_set():
sys.exit(1)
role, scenario, platform_name, _img, _cmd = parse_pair(pair)
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env)
cwd = resolve_role_dir(role, roles_root, repo_root)
start = time.time()
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(cwd),
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()
elapsed = time.time() - start
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": False,
"error": "Cancelled — another runner failed",
}
)
if junit_output:
write_junit_report(junit_output, testcases, current_index)
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
elapsed = time.time() - start
passed = rc == 0
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": passed,
"error": f"Exit code: {rc}" if not passed else None,
}
)
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally:
stop_event.set()
sys.exit(0)
if __name__ == "__main__": # pragma: no cover
cli()
+48
View File
@@ -0,0 +1,48 @@
"""Supported OS platform matrix for molecule tests.
Single source of truth for the platform list used by both:
- ``devx.molecule.distribute_molecule`` (CI parallel matrix)
- local sequential runners
Keeping this in a dedicated module avoids cross-imports between
dev tools and CI scripts.
"""
from __future__ import annotations
import json
from pathlib import Path
#: Default supported OS platform matrix.
#: Each entry maps a short name to (image, command).
#: Uses the project's pre-built molecule-test-base image with
#: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures.
PLATFORMS: list[dict[str, str]] = [
{
"name": "ubuntu-2604",
"image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
"command": "sleep infinity",
},
]
def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, str]]:
"""Load platforms from a JSON file, falling back to PLATFORMS.
Args:
platforms_file: Path to a JSON file with a list of platform dicts.
Each dict must have ``name``, ``image``, and ``command`` keys.
Returns:
List of platform dictionaries.
"""
if platforms_file is None:
return PLATFORMS
path = Path(platforms_file)
if not path.is_file():
return PLATFORMS
with path.open() as f:
data = json.load(f)
if not isinstance(data, list) or not data:
return PLATFORMS
return data
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Start a Docker daemon inside a CI runner container (Docker-in-Docker).
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may not have a
Docker daemon running. This module starts ``dockerd`` in the background
and waits for it to become ready, or exits immediately if Docker is
already available.
Usage::
python3 -m devx.molecule.start_docker [--timeout 30]
"""
from __future__ import annotations
import subprocess # nosec B404
import sys
import time
import click
from devx.i18n import _
DEFAULT_TIMEOUT = 30
DOCKERD_LOG = "/var/log/dockerd.log"
def is_docker_ready() -> bool:
"""Check if the Docker daemon is responding."""
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
)
return result.returncode == 0
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Start dockerd in the background and wait for it to be ready.
Returns ``True`` if Docker is ready (either already running or
successfully started), ``False`` if it failed to start within
the timeout.
"""
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Starting Docker daemon..."))
log_file = open(DOCKERD_LOG, "w") # noqa: SIM115
subprocess.Popen( # nosec B603 B607
["dockerd"],
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
for _i in range(timeout):
if is_docker_ready():
click.echo(_("Docker daemon started"))
return True
time.sleep(1)
click.echo(_("Docker daemon failed to start"))
return False
@click.command()
@click.option(
"--timeout",
default=DEFAULT_TIMEOUT,
type=int,
help="Seconds to wait for Docker daemon to start (default: 30).",
)
def main(timeout: int) -> None:
"""Start Docker daemon for CI molecule tests."""
if start_docker_daemon(timeout):
sys.exit(0)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""OpenTofu output helpers for CI/CD deployment scripts.
Provides reusable functions for extracting values from ``tofu output``
in a structured way. This eliminates duplicated ``subprocess.run``
boilerplate across deployment and smoke-test scripts.
Typical usage::
from devx.opentofu import get_tofu_output, get_tofu_vm_ip
vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
"""
from __future__ import annotations
import json
import subprocess # nosec B404
from pathlib import Path
from typing import Any
def get_tofu_output(
output_name: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Any:
"""Run ``tofu output -json <output_name>`` and return parsed JSON.
Args:
output_name: The OpenTofu output name to query (e.g. ``customer_vms``).
cwd: Directory to run the command in (the tofu env directory).
env: Environment variables for the subprocess (e.g. ``{"HCLOUD_TOKEN": ...}``).
If ``None``, inherits the current environment.
Returns:
Parsed JSON value from the tofu output.
Raises:
RuntimeError: If ``tofu output`` exits with a non-zero code.
json.JSONDecodeError: If stdout is not valid JSON.
"""
result = subprocess.run( # nosec B603, B607
["tofu", "output", "-json", output_name],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
check=False,
env=env,
)
if result.returncode != 0:
raise RuntimeError(f"tofu output failed: {result.stderr}")
return json.loads(result.stdout)
def get_tofu_vm_ip(
output_name: str,
vm_key: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
ip_field: str = "ipv4",
) -> str:
"""Extract a VM IPv4 address from a tofu output map.
The output is expected to be a JSON object mapping VM names to objects
containing an IP field (default ``ipv4``)::
{"staging": {"ipv4": "1.2.3.4", ...}, ...}
Args:
output_name: The tofu output name (e.g. ``customer_vms``).
vm_key: The key inside the output map (e.g. ``"staging"``).
cwd: Directory to run the command in.
env: Environment variables for the subprocess.
ip_field: The field name for the IP address (default ``ipv4``).
Returns:
The IP address string, or empty string if not found.
"""
data = get_tofu_output(output_name, cwd=cwd, env=env)
if not isinstance(data, dict):
return ""
return str(data.get(vm_key, {}).get(ip_field, ""))
def get_tofu_vm_field(
output_name: str,
vm_key: str,
field: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Extract an arbitrary field from a VM entry in tofu output.
Like :func:`get_tofu_vm_ip` but for any field (e.g. ``volume_linux_device``).
Args:
output_name: The tofu output name.
vm_key: The key inside the output map.
field: The field name to extract.
cwd: Directory to run the command in.
env: Environment variables for the subprocess.
Returns:
The field value as a string, or empty string if not found.
"""
data = get_tofu_output(output_name, cwd=cwd, env=env)
if not isinstance(data, dict):
return ""
return str(data.get(vm_key, {}).get(field, ""))
View File
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Run unit tests and enforce execution-time budgets.
Checks two quality gates:
1. **Total suite time** must not exceed ``--max-seconds``.
2. **Per-test time** no individual test may exceed ``--max-single-seconds``.
Usage:
python3 -m devx.tools.check_test_speed [--max-seconds N] [--max-single-seconds S]
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
that pytest emits per-test timing lines alongside the summary. Both the
total wall-clock time and individual test durations are parsed and validated.
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import click
from devx.i18n import _
DEFAULT_MAX_SECONDS = 10.0
DEFAULT_MAX_SINGLE_SECONDS = 0.5
TEST_COMMAND = ["make", "test-unit"]
# Matches pytest summary line: "234 passed in 0.70s"
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
# Matches per-test duration lines from --durations=0:
# 0.51s call tests/test_foo.py::test_bar
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
def run_tests() -> tuple[str, str]:
"""Execute the unit-test suite and return (stdout, stderr).
Sets ``PYTEST_ADDOPTS=--durations=0`` so pytest emits per-test timings.
"""
env = os.environ.copy()
existing = env.get("PYTEST_ADDOPTS", "")
env["PYTEST_ADDOPTS"] = f"--durations=0 {existing}".strip()
result = subprocess.run( # nosec B603
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
env=env,
)
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 parse_per_test_durations(output: str) -> list[tuple[str, float]]:
"""Extract per-test timings from ``--durations=0`` output.
Returns a list of ``(test_name, seconds)`` tuples sorted by duration
(slowest first).
"""
durations: list[tuple[str, float]] = []
for line in output.splitlines():
match = _DURATION_LINE_RE.match(line.strip())
if match:
durations.append((match.group(2).strip(), float(match.group(1))))
durations.sort(key=lambda x: x[1], reverse=True)
return durations
def check_speed(duration: float, max_seconds: float) -> None:
"""Validate total 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 check_per_test_speed(
durations: list[tuple[str, float]],
max_single_seconds: float,
) -> list[str]:
"""Return a list of violation messages for tests exceeding the per-test limit.
An empty list means all tests are within budget.
"""
violations: list[str] = []
for name, elapsed in durations:
if elapsed > max_single_seconds:
violations.append(
_(
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). "
"Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
name=name,
elapsed=elapsed,
limit=max_single_seconds,
)
)
return violations
def main(max_seconds: float, max_single_seconds: float) -> None:
"""Run tests, parse timings, and enforce both budgets."""
stdout, stderr = run_tests()
combined = stdout + "\n" + stderr
click.echo(combined, err=False)
duration = parse_duration(combined)
check_speed(duration, max_seconds)
if max_single_seconds > 0:
per_test = parse_per_test_durations(combined)
violations = check_per_test_speed(per_test, max_single_seconds)
if violations:
msg = _(
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
count=len(violations),
limit=max_single_seconds,
)
click.echo(f"\n{msg}", err=True)
for v in violations:
click.echo(f" - {v}", err=True)
raise click.ClickException(msg)
click.echo(
_(
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
duration=duration,
max=max_seconds,
single=max_single_seconds,
)
)
@click.command()
@click.option(
"--max-seconds",
type=float,
default=DEFAULT_MAX_SECONDS,
show_default=True,
help="Maximum allowed total execution time in seconds.",
)
@click.option(
"--max-single-seconds",
type=float,
default=DEFAULT_MAX_SINGLE_SECONDS,
show_default=True,
help="Maximum allowed per-test time in seconds (0 to disable).",
)
def cli(max_seconds: float, max_single_seconds: float) -> None:
main(max_seconds, max_single_seconds)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Configure repository: branch protection + repo settings via Gitea REST API.
Uses ``GiteaClient`` for branch protection and repo settings.
The ``tea`` CLI is used for label creation if available, with a
fallback to ``GiteaClient`` if tea is not installed.
Usage:
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
"""
from __future__ import annotations
import http
import os
from typing import Any, cast
import click
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.exceptions import APIError
from devx.i18n import _
def _default_status_checks() -> list[str]:
"""Read status check contexts from DEVX_STATUS_CHECKS env var or use default."""
env_checks = os.environ.get("DEVX_STATUS_CHECKS", "")
if env_checks:
return [c.strip() for c in env_checks.split(",") if c.strip()]
return ["CI / quality (pull_request)"]
def _default_branch_protection_config() -> dict[str, Any]:
"""Build the default branch protection config.
The ``status_check_contexts`` are read from the ``DEVX_STATUS_CHECKS``
environment variable (comma-separated) or default to just the quality
check context.
Push whitelist is disabled the release script pushes directly to
master (release commits). Since there are no manual reviews yet,
requiring PRs for every push adds complexity without benefit.
"""
return {
"branch_name": "master",
"enable_push": True,
"enable_push_whitelist": False,
"push_whitelist_usernames": [],
"enable_status_check": True,
"status_check_contexts": _default_status_checks(),
"required_approvals": 0,
"dismiss_stale_approvals": True,
"block_on_outdated_branch": True,
"block_on_rejected_reviews": True,
"block_on_official_review_requests": True,
}
def _default_repo_settings_config() -> dict[str, Any]:
"""Build the default repository settings config."""
return {
"default_delete_branch_after_merge": 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 configure_repo(
token: str,
owner: str,
repo: str,
branch: str = "master",
branch_protection_config: dict[str, Any] | None = None,
repo_settings_config: dict[str, Any] | None = None,
api_url: str | None = None,
) -> None:
"""Configure branch protection and repository settings via the Gitea API.
Args:
token: Gitea API token with admin rights.
owner: Repository owner (user or organisation).
repo: Repository name.
branch: Branch to protect (default: ``master``).
branch_protection_config: Branch protection settings dict.
If None, uses defaults from :func:`_default_branch_protection_config`.
repo_settings_config: Repository settings dict.
If None, uses defaults from :func:`_default_repo_settings_config`.
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
"""
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
url = api_url or GITEA_API_URL
client = GiteaClient(url, token, owner, repo)
bp_config = branch_protection_config or _default_branch_protection_config()
rs_config = repo_settings_config or _default_repo_settings_config()
try:
click.echo(_("Configuring branch protection for {branch}...", branch=branch))
client.ensure_branch_protection(branch, bp_config)
click.echo(_(" - Direct pushes: BLOCKED (require PR, whitelisted users can push)"))
click.echo(
_(
" - Required approvals: {count}",
count=bp_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], bp_config["status_check_contexts"]))
click.echo(_(" - Required status checks: {checks}", checks=checks))
click.echo("")
click.echo(_("Configuring repository settings..."))
client.update_repo_settings(cast(dict[str, object], rs_config))
click.echo(_(" - Auto-delete branch after merge: yes"))
click.echo("")
click.echo(_("Repository configuration complete."))
except APIError as e:
_handle_http_error(e)
@click.command()
@click.option("--repo", default=None, help="Repository name (default: from DEVX_REPO_NAME env var).")
@click.option("--owner", default=None, help="Repository owner (default: from DEVX_REPO_OWNER env var).")
@click.option("--branch", default="master", help="Branch to protect (default: master).")
@click.option(
"--api-url",
default=None,
help="Gitea API base URL (default: from DEVX_GITEA_API_URL env var).",
)
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
"""Configure branch protection and repository settings via the Gitea API."""
token = os.environ.get("REPO_TOKEN", "")
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "")
if not repo:
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
if owner is None:
owner = REPO_OWNER
configure_repo(
token=token,
owner=owner,
repo=repo,
branch=branch,
api_url=api_url,
)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+271
View File
@@ -0,0 +1,271 @@
#!/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 -m devx.tools.generate_badges --output-dir .badges/
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import sys
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parents[4]
_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 _find_package_init() -> Path | None:
"""Find the first package __init__.py under src/ that defines __version__."""
src_dir = REPO_ROOT / "src"
if not src_dir.exists():
return None
for init_file in src_dir.rglob("__init__.py"):
try:
content = init_file.read_text()
except OSError:
continue
if "__version__" in content:
return init_file
return None
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."""
init_file = _find_package_init()
if init_file is None:
return "unknown"
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(
[
sys.executable,
"-m",
"pytest",
"tests/",
"-v",
"--cov=src/devx",
"--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(
[
sys.executable,
"-m",
"devx.ci.doc_coverage",
]
)
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([sys.executable, "-m", "ruff", "check", "src/", "tests/"])
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"])
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
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
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Generate a cliff.toml configuration file for a project.
Produces a git-cliff configuration with the correct task ID prefix
preprocessor, matching the format used by devx itself. Downstream
repos can use this to avoid duplicating the entire cliff.toml by hand.
Usage::
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --output cliff.toml
python -m devx.tools.generate_cliff_config --prefix GRM --force
"""
from __future__ import annotations
from pathlib import Path
import click
from devx.config import TASK_PREFIX
from devx.i18n import _
# Template uses __PREFIX__ and __PREFIX_REGEX__ as placeholders to avoid
# conflicts with Jinja2's {{ }} and {% %} syntax in the cliff.toml body.
CLIFF_TEMPLATE = """\
# git-cliff configuration for __PREFIX__
# https://git-cliff.org/docs/configuration
# Generated by: python -m devx.tools.generate_cliff_config --prefix __PREFIX__
[changelog]
header = \"\"\"
# Changelog\\n
All notable changes to this project will be documented in this file.\\n
\"\"\"
body = \"\"\"
{% if version %}\\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\\
## [unreleased]
{% endif %}\\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\\
{% if commit.breaking %}[**breaking**] {% endif %}\\
{{ commit.message | upper_first }}\\
{% endfor %}
{% endfor %}
\"\"\"
trim = true
render_always = true
[git]
conventional_commits = true
filter_unconventional = true
require_conventional = false
split_commits = false
protect_breaking_commits = false
filter_commits = false
fail_on_unmatched_commit = false
use_branch_tags = false
topo_order = false
topo_order_commits = true
sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip __PREFIX__-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^__PREFIX_REGEX__-\\\\d+:\\\\s+", replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
# Skip infrastructure-only commits — they don't affect users
{ message = "^doc", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
# Skip release commits — they are release artifacts, not features
{ message = "^release:", skip = true },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
# Skip anything that doesn't match above — safe default
{ message = ".*", skip = true },
]
[bump]
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
"""
def _generate(prefix: str) -> str:
"""Generate cliff.toml content for the given prefix."""
prefix_regex = prefix.replace("\\", "\\\\")
return CLIFF_TEMPLATE.replace("__PREFIX__", prefix).replace("__PREFIX_REGEX__", prefix_regex)
@click.command()
@click.option(
"--prefix",
default=TASK_PREFIX,
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
)
@click.option(
"--output",
"-o",
default="cliff.toml",
type=click.Path(),
help="Output file path (default: cliff.toml).",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing file without prompting.",
)
def main(prefix: str, output: str, force: bool) -> None:
"""Generate a cliff.toml configuration file."""
output_path = Path(output)
if output_path.exists() and not force:
raise click.ClickException(
_(
"{file} already exists. Use --force to overwrite.",
file=str(output_path),
)
)
content = _generate(prefix)
output_path.write_text(content)
click.echo(
_(
"Generated {file} with prefix '{prefix}'.",
file=str(output_path),
prefix=prefix,
)
)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+69
View File
@@ -0,0 +1,69 @@
#!/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
+221
View File
@@ -0,0 +1,221 @@
#!/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 -m devx.tools.install_tools # install all
python3 -m devx.tools.install_tools --tool actionlint # install one
python3 -m devx.tools.install_tools --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
# git-cliff uses x86_64/arm64 in release asset names (not amd64)
machine = platform.machine().lower()
arch = "x86_64" if machine in {"x86_64", "amd64"} else "arm64"
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
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Project setup: install Python deps, Ansible collections, pre-commit hooks, and tea CLI login.
Usage::
python3 -m devx.tools.setup --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()
def _run(cmd: list[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, extras: str = "dev") -> None:
"""Install the project with the specified extras in editable mode.
In CI (system Python with PIP_BREAK_SYSTEM_PACKAGES=1), a first attempt
uses --break-system-packages. If that fails (e.g. debian-installed
packages without RECORD files), retry with --ignore-installed to skip
uninstalling system packages entirely.
"""
pip = str(Path(bin_dir) / "pip")
cmd = [pip, "install", "-e", f".[{extras}]"]
if os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
cmd.append("--break-system-packages")
result = subprocess.run(cmd, check=False) # nosec B603
if result.returncode != 0 and os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
click.echo(" Retrying with --ignore-installed to bypass system packages...")
cmd.append("--ignore-installed")
_run(cmd)
elif result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
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])
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist."""
galaxy = shutil.which("ansible-galaxy") or 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)])
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 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 — run 'make install-tools' to install it.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
gitea_url = api_url.replace("/api/v1", "")
login_name = "devx"
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
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:
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."""
devx = str(Path(bin_dir) / "devx")
pre_commit = str(Path(bin_dir) / "pre-commit")
for tool in [devx, 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.")
@click.option(
"--extras",
default="dev",
help="Dependency group to install: ci, lint, or dev (default: dev).",
)
@click.option(
"--no-pre-commit",
is_flag=True,
default=False,
help="Skip pre-commit hook installation.",
)
@click.option(
"--no-tea-login",
is_flag=True,
default=False,
help="Skip tea CLI login configuration.",
)
@click.option(
"--no-ansible-collections",
is_flag=True,
default=False,
help="Skip Ansible Galaxy collection installation.",
)
def main(
bin_dir: str,
extras: str,
no_pre_commit: bool,
no_tea_login: bool,
no_ansible_collections: bool,
) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
_install_ansible_collections(bin_dir)
if not no_pre_commit:
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
if not no_tea_login:
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
+1241
View File
@@ -0,0 +1,1241 @@
{
"\n=== Summary ===": {
"bg": "\n=== Summary ===",
"de": "\n=== Summary ===",
"en": "\n=== Summary ===",
"ru": "\n=== Summary ===",
"zh": "\n=== Summary ==="
},
"\nAll documentation coverage checks passed!": {
"bg": "\nAll documentation coverage checks passed!",
"de": "\nAll documentation coverage checks passed!",
"en": "\nAll documentation coverage checks passed!",
"ru": "\nAll documentation coverage checks passed!",
"zh": "\nAll documentation coverage checks passed!"
},
"\nCHANGELOG version ordering:": {
"bg": "\nCHANGELOG version ordering:",
"de": "\nCHANGELOG version ordering:",
"en": "\nCHANGELOG version ordering:",
"ru": "\nCHANGELOG version ordering:",
"zh": "\nCHANGELOG version ordering:"
},
"\nChecking CI script documentation in ci-cd-workflow.md...": {
"bg": "\nChecking CI script documentation in ci-cd-workflow.md...",
"de": "\nChecking CI script documentation in ci-cd-workflow.md...",
"en": "\nChecking CI script documentation in ci-cd-workflow.md...",
"ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
},
"\nChecking module documentation in architecture.md...": {
"bg": "\nChecking module documentation in architecture.md...",
"de": "\nChecking module documentation in architecture.md...",
"en": "\nChecking module documentation in architecture.md...",
"ru": "\nChecking module documentation in architecture.md...",
"zh": "\nChecking module documentation in architecture.md..."
},
"\nDoc coverage: {covered}/{total} ({pct}%)": {
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
"de": "\nDoc coverage: {covered}/{total} ({pct}%)",
"en": "\nDoc coverage: {covered}/{total} ({pct}%)",
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
},
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
},
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
},
"\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
"bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
},
"\nIntegrity check FAILED ({count} issues):": {
"bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):",
"en": "\nIntegrity check FAILED ({count} issues):",
"ru": "\nIntegrity check FAILED ({count} issues):",
"zh": "\nIntegrity check FAILED ({count} issues):"
},
"\nIntegrity check passed — all {count} pages verified.": {
"bg": "\nIntegrity check passed — all {count} pages verified.",
"de": "\nIntegrity check passed — all {count} pages verified.",
"en": "\nIntegrity check passed — all {count} pages verified.",
"ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified."
},
"\nLatest tag: {tag}": {
"bg": "\nLatest tag: {tag}",
"de": "\nLatest tag: {tag}",
"en": "\nLatest tag: {tag}",
"ru": "\nLatest tag: {tag}",
"zh": "\nLatest tag: {tag}"
},
"\nMissing documentation:": {
"bg": "\nMissing documentation:",
"de": "\nMissing documentation:",
"en": "\nMissing documentation:",
"ru": "\nMissing documentation:",
"zh": "\nMissing documentation:"
},
"\nResult: {status}": {
"bg": "\nResult: {status}",
"de": "\nResult: {status}",
"en": "\nResult: {status}",
"ru": "\nResult: {status}",
"zh": "\nResult: {status}"
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
},
"\nRunning full wiki integrity check...": {
"bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...",
"en": "\nRunning full wiki integrity check...",
"ru": "\nRunning full wiki integrity check...",
"zh": "\nRunning full wiki integrity check..."
},
"\nTag → Commit alignment:": {
"bg": "\nTag → Commit alignment:",
"de": "\nTag → Commit alignment:",
"en": "\nTag → Commit alignment:",
"ru": "\nTag → Commit alignment:",
"zh": "\nTag → Commit alignment:"
},
"\nUntagged release commits:": {
"bg": "\nUntagged release commits:",
"de": "\nUntagged release commits:",
"en": "\nUntagged release commits:",
"ru": "\nUntagged release commits:",
"zh": "\nUntagged release commits:"
},
"\nUser-facing changes ({count}):": {
"bg": "\nUser-facing changes ({count}):",
"de": "\nUser-facing changes ({count}):",
"en": "\nUser-facing changes ({count}):",
"ru": "\nUser-facing changes ({count}):",
"zh": "\nUser-facing changes ({count}):"
},
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
},
"\nVerification passed — all wiki pages have correct content.": {
"bg": "\nVerification passed — all wiki pages have correct content.",
"de": "\nVerification passed — all wiki pages have correct content.",
"en": "\nVerification passed — all wiki pages have correct content.",
"ru": "\nVerification passed — all wiki pages have correct content.",
"zh": "\nVerification passed — all wiki pages have correct content."
},
"\nVerifying wiki pages have content...": {
"bg": "\nVerifying wiki pages have content...",
"de": "\nVerifying wiki pages have content...",
"en": "\nVerifying wiki pages have content...",
"ru": "\nVerifying wiki pages have content...",
"zh": "\nVerifying wiki pages have content..."
},
"\nWorkflow-only changes ({count}):": {
"bg": "\nWorkflow-only changes ({count}):",
"de": "\nWorkflow-only changes ({count}):",
"en": "\nWorkflow-only changes ({count}):",
"ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):"
},
"\n[dry-run] Changelog:\n{changelog}": {
"bg": "\n[dry-run] Changelog:\n{changelog}",
"de": "\n[dry-run] Changelog:\n{changelog}",
"en": "\n[dry-run] Changelog:\n{changelog}",
"ru": "\n[dry-run] Changelog:\n{changelog}",
"zh": "\n[dry-run] Changelog:\n{changelog}"
},
"\n{label} files changed ({count}):": {
"bg": "\n{label} files changed ({count}):",
"de": "\n{label} files changed ({count}):",
"en": "\n{label} files changed ({count}):",
"ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):"
},
"\n{tag} files ({count}):": {
"bg": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):",
"en": "\n{tag} files ({count}):",
"ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):"
},
" - Auto-delete branch after merge: yes": {
"bg": " - Автоматично изтриване на клон след сливане: да",
"de": " - Branch nach Merge automatisch löschen: ja",
"en": " - Auto-delete branch after merge: yes",
"ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是"
},
" - Block outdated branches: yes": {
"bg": " - Блокиране на остарели клонове: да",
"de": " - Veraltete Branches blockieren: ja",
"en": " - Block outdated branches: yes",
"ru": " - Блокировать устаревшие ветки: да",
"zh": " - 阻止过时分支: 是"
},
" - Block rejected reviews: yes": {
"bg": " - Блокиране на отхвърлени рецензии: да",
"de": " - Abgelehnte Reviews blockieren: ja",
"en": " - Block rejected reviews: yes",
"ru": " - Блокировать отклонённые ревью: да",
"zh": " - 阻止被拒绝的审查: 是"
},
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
},
" - Dismiss stale approvals: yes": {
"bg": " - Анулиране на остарели одобрения: да",
"de": " - Veraltete Genehmigungen ablehnen: ja",
"en": " - Dismiss stale approvals: yes",
"ru": " - Отклонять устаревшие одобрения: да",
"zh": " - 忽略过时审批: 是"
},
" - Required approvals: {count}": {
"bg": " - Необходими одобрения: {count}",
"de": " - Erforderliche Genehmigungen: {count}",
"en": " - Required approvals: {count}",
"ru": " - Требуемые одобрения: {count}",
"zh": " - 必需审批数: {count}"
},
" - Required status checks: {checks}": {
"bg": " - Необходими проверки на състоянието: {checks}",
"de": " - Erforderliche Status-Checks: {checks}",
"en": " - Required status checks: {checks}",
"ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}"
},
" Created: {title}": {
"bg": " Created: {title}",
"de": " Created: {title}",
"en": " Created: {title}",
"ru": " Created: {title}",
"zh": " Created: {title}"
},
" FAIL: {title} — content mismatch or empty!": {
"bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!",
"en": " FAIL: {title} — content mismatch or empty!",
"ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!"
},
" MISSING: devx {cmd}": {
"bg": " ЛИПСВА: devx {cmd}",
"de": " FEHLT: devx {cmd}",
"en": " MISSING: devx {cmd}",
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
"zh": " 缺失: devx {cmd}"
},
" MISSING: {module}": {
"bg": " MISSING: {module}",
"de": " MISSING: {module}",
"en": " MISSING: {module}",
"ru": " MISSING: {module}",
"zh": " MISSING: {module}"
},
" MISSING: {script}": {
"bg": " MISSING: {script}",
"de": " MISSING: {script}",
"en": " MISSING: {script}",
"ru": " MISSING: {script}",
"zh": " MISSING: {script}"
},
" OK: devx {cmd}": {
"bg": " ОК: devx {cmd}",
"de": " OK: devx {cmd}",
"en": " OK: devx {cmd}",
"ru": " ОК: devx {cmd}",
"zh": " 正常: devx {cmd}"
},
" OK: {module}": {
"bg": " OK: {module}",
"de": " OK: {module}",
"en": " OK: {module}",
"ru": " OK: {module}",
"zh": " OK: {module}"
},
" OK: {script}": {
"bg": " OK: {script}",
"de": " OK: {script}",
"en": " OK: {script}",
"ru": " OK: {script}",
"zh": " OK: {script}"
},
" OK: {title} ({chars} chars)": {
"bg": " OK: {title} ({chars} chars)",
"de": " OK: {title} ({chars} chars)",
"en": " OK: {title} ({chars} chars)",
"ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)"
},
" Updated: {title}": {
"bg": " Updated: {title}",
"de": " Updated: {title}",
"en": " Updated: {title}",
"ru": " Updated: {title}",
"zh": " Updated: {title}"
},
"--skip-build: skipping package build and PyPI publish.": {
"bg": "--skip-build: skipping package build and PyPI publish.",
"de": "--skip-build: skipping package build and PyPI publish.",
"en": "--skip-build: skipping package build and PyPI publish.",
"ru": "--skip-build: skipping package build and PyPI publish.",
"zh": "--skip-build: skipping package build and PyPI publish."
},
"=== Release Alignment Verification ===\n": {
"bg": "=== Release Alignment Verification ===\n",
"de": "=== Release Alignment Verification ===\n",
"en": "=== Release Alignment Verification ===\n",
"ru": "=== Release Alignment Verification ===\n",
"zh": "=== Release Alignment Verification ===\n"
},
"API poll warning: {exc}": {
"bg": "API poll warning: {exc}",
"de": "API poll warning: {exc}",
"en": "API poll warning: {exc}",
"ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}"
},
"All molecule tests passed.": {
"bg": "All molecule tests passed.",
"de": "All molecule tests passed.",
"en": "All molecule tests passed.",
"ru": "All molecule tests passed.",
"zh": "All molecule tests passed."
},
"Another molecule runner failed. Stopping this runner early.": {
"bg": "Another molecule runner failed. Stopping this runner early.",
"de": "Another molecule runner failed. Stopping this runner early.",
"en": "Another molecule runner failed. Stopping this runner early.",
"ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early."
},
"Bumping version: {current} -> v{new_version}": {
"bg": "Bumping version: {current} -> v{new_version}",
"de": "Bumping version: {current} -> v{new_version}",
"en": "Bumping version: {current} -> v{new_version}",
"ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}"
},
"Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...",
"en": "Checking CLI command documentation...",
"ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..."
},
"Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}",
"en": "Command failed ({cmd}): {stderr}",
"ru": "Command failed ({cmd}): {stderr}",
"zh": "Command failed ({cmd}): {stderr}"
},
"Comparing {base}..{head} ({count} files changed)": {
"bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)",
"en": "Comparing {base}..{head} ({count} files changed)",
"ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "Comparing {base}..{head} ({count} files changed)"
},
"Configuring branch protection for {branch}...": {
"bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...",
"en": "Configuring branch protection for {branch}...",
"ru": "Настройка защиты ветки {branch}...",
"zh": "正在配置 {branch} 的分支保护..."
},
"Configuring repository settings...": {
"bg": "Конфигуриране на настройките на хранилището...",
"de": "Repository-Einstellungen konfigurieren...",
"en": "Configuring repository settings...",
"ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..."
},
"Could not extract conventional commit message from PR commits.": {
"bg": "Could not extract conventional commit message from PR commits.",
"de": "Could not extract conventional commit message from PR commits.",
"en": "Could not extract conventional commit message from PR commits.",
"ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not extract conventional commit message from PR commits."
},
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
},
"Could not find __version__ in {file}": {
"bg": "Could not find __version__ in {file}",
"de": "Could not find __version__ in {file}",
"en": "Could not find __version__ in {file}",
"ru": "Could not find __version__ in {file}",
"zh": "Could not find __version__ in {file}"
},
"Could not parse test execution time from output.": {
"bg": "Could not parse test execution time from output.",
"de": "Could not parse test execution time from output.",
"en": "Could not parse test execution time from output.",
"ru": "Could not parse test execution time from output.",
"zh": "Could not parse test execution time from output."
},
"Created issue #{issue_id}: {title}": {
"bg": "Created issue #{issue_id}: {title}",
"de": "Created issue #{issue_id}: {title}",
"en": "Created issue #{issue_id}: {title}",
"ru": "Created issue #{issue_id}: {title}",
"zh": "Created issue #{issue_id}: {title}"
},
"Created release commit.": {
"bg": "Created release commit.",
"de": "Created release commit.",
"en": "Created release commit.",
"ru": "Created release commit.",
"zh": "Created release commit."
},
"Docker daemon already running": {
"bg": "Docker daemon already running",
"de": "Docker-Daemon läuft bereits",
"en": "Docker daemon already running",
"ru": "Docker-демон уже запущен",
"zh": "Docker 守护进程已在运行"
},
"Docker daemon failed to start": {
"bg": "Docker daemon failed to start",
"de": "Docker-Daemon konnte nicht gestartet werden",
"en": "Docker daemon failed to start",
"ru": "Не удалось запустить Docker-демон",
"zh": "Docker 守护进程启动失败"
},
"Docker daemon started": {
"bg": "Docker daemon started",
"de": "Docker-Daemon gestartet",
"en": "Docker daemon started",
"ru": "Docker-демон запущен",
"zh": "Docker 守护进程已启动"
},
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
},
"ERROR: REPO_TOKEN is not set.": {
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
"en": "ERROR: REPO_TOKEN is not set.",
"ru": "ОШИБКА: REPO_TOKEN не задан.",
"zh": "错误:未设置 REPO_TOKEN。"
},
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
"de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.",
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
},
"ERROR: Tag consistency check failed. Existing tags are misaligned:": {
"bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"de": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"en": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:"
},
"ERROR: VIKUNJA_TOKEN is not set.": {
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
"en": "ERROR: VIKUNJA_TOKEN is not set.",
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
"zh": "错误:未设置 VIKUNJA_TOKEN。"
},
"ERROR: mapping.json not found at {path}": {
"bg": "ERROR: mapping.json not found at {path}",
"de": "ERROR: mapping.json not found at {path}",
"en": "ERROR: mapping.json not found at {path}",
"ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}"
},
"FAILED: {pair} exited with code {code}": {
"bg": "FAILED: {pair} exited with code {code}",
"de": "FAILED: {pair} exited with code {code}",
"en": "FAILED: {pair} exited with code {code}",
"ru": "FAILED: {pair} exited with code {code}",
"zh": "FAILED: {pair} exited with code {code}"
},
"Failed to create issue via tea: {error}": {
"bg": "Failed to create issue via tea: {error}",
"de": "Failed to create issue via tea: {error}",
"en": "Failed to create issue via tea: {error}",
"ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}"
},
"Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.",
"en": "Found {count} existing wiki pages.",
"ru": "Found {count} existing wiki pages.",
"zh": "Found {count} existing wiki pages."
},
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
},
"Generated {file} with prefix '{prefix}'.": {
"bg": "Generated {file} with prefix '{prefix}'.",
"de": "Generated {file} with prefix '{prefix}'.",
"en": "Generated {file} with prefix '{prefix}'.",
"ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'."
},
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
"bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
},
"HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": {
"bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment."
},
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": {
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
},
"HTTP error: {status} — {message}": {
"bg": "HTTP грешка: {status} — {message}",
"de": "HTTP-Fehler: {status} — {message}",
"en": "HTTP error: {status} — {message}",
"ru": "Ошибка HTTP: {status} — {message}",
"zh": "HTTP 错误: {status} — {message}"
},
"HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": {
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
"de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
},
"Head branch is behind master. Pulling and rebasing...": {
"bg": "Head branch is behind master. Pulling and rebasing...",
"de": "Head branch is behind master. Pulling and rebasing...",
"en": "Head branch is behind master. Pulling and rebasing...",
"ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "Head branch is behind master. Pulling and rebasing..."
},
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
},
"Integration tests cancelled — another runner failed.": {
"bg": "Integration tests cancelled — another runner failed.",
"de": "Integration tests cancelled — another runner failed.",
"en": "Integration tests cancelled — another runner failed.",
"ru": "Integration tests cancelled — another runner failed.",
"zh": "Integration tests cancelled — another runner failed."
},
"Integration tests failed with exit code {code}": {
"bg": "Integration tests failed with exit code {code}",
"de": "Integration tests failed with exit code {code}",
"en": "Integration tests failed with exit code {code}",
"ru": "Integration tests failed with exit code {code}",
"zh": "Integration tests failed with exit code {code}"
},
"Integration tests passed.": {
"bg": "Integration tests passed.",
"de": "Integration tests passed.",
"en": "Integration tests passed.",
"ru": "Integration tests passed.",
"zh": "Integration tests passed."
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
},
"Lint passed.": {
"bg": "Lint passed.",
"de": "Lint passed.",
"en": "Lint passed.",
"ru": "Lint passed.",
"zh": "Lint passed."
},
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
},
"Mapped file {file} not found. Update mapping.json or create the file.": {
"bg": "Mapped file {file} not found. Update mapping.json or create the file.",
"de": "Mapped file {file} not found. Update mapping.json or create the file.",
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
},
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
},
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
},
"Merged {count} reports: {tests} tests, {failures} failures → {output}": {
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
},
"Module {mod} has no main() function": {
"bg": "Модул {mod} няма функция main()",
"de": "Modul {mod} hat keine main()-Funktion",
"en": "Module {mod} has no main() function",
"ru": "Модуль {mod} не имеет функции main()",
"zh": "模块 {mod} 没有 main() 函数"
},
"Molecule directory not found: {path}": {
"bg": "Директорията на molecule не е намерена: {path}",
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
"en": "Molecule directory not found: {path}",
"ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}"
},
"Nice! Gitea release {tag} created.": {
"bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.",
"en": "Nice! Gitea release {tag} created.",
"ru": "Отлично! Gitea release {tag} создан.",
"zh": "不错!Gitea release {tag} 已创建。"
},
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
},
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
"bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
},
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"No JUnit reports found matching {pattern} — skipping merge.": {
"bg": "No JUnit reports found matching {pattern} — skipping merge.",
"de": "No JUnit reports found matching {pattern} — skipping merge.",
"en": "No JUnit reports found matching {pattern} — skipping merge.",
"ru": "No JUnit reports found matching {pattern} — skipping merge.",
"zh": "No JUnit reports found matching {pattern} — skipping merge."
},
"No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.",
"en": "No changes between {base} and {head}.",
"ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}."
},
"No staged changes — version and changelog already up to date.": {
"bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.",
"en": "No staged changes — version and changelog already up to date.",
"ru": "No staged changes — version and changelog already up to date.",
"zh": "No staged changes — version and changelog already up to date."
},
"No tags found — treating all changes as user-facing.": {
"bg": "No tags found — treating all changes as user-facing.",
"de": "No tags found — treating all changes as user-facing.",
"en": "No tags found — treating all changes as user-facing.",
"ru": "No tags found — treating all changes as user-facing.",
"zh": "No tags found — treating all changes as user-facing."
},
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
"bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
},
"No unreleased changes found. Nothing to release.": {
"bg": "No unreleased changes found. Nothing to release.",
"de": "No unreleased changes found. Nothing to release.",
"en": "No unreleased changes found. Nothing to release.",
"ru": "No unreleased changes found. Nothing to release.",
"zh": "No unreleased changes found. Nothing to release."
},
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
"en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
},
"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": {
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"en": "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",
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
},
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
"bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI."
},
"Oops! Gitea PyPI registry publish failed:\n{stderr}": {
"bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}",
"de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}",
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
"ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}",
"zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}"
},
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}": {
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
},
"Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
},
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
},
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
},
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
"bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
},
"Oops! Package build failed:\n{stderr}": {
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
"en": "Oops! Package build failed:\n{stderr}",
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
"zh": "哎呀!包构建失败:\n{stderr}"
},
"Oops! PyPI publish failed:\n{stderr}": {
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
"en": "Oops! PyPI publish failed:\n{stderr}",
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
},
"PASSED: {pair}": {
"bg": "PASSED: {pair}",
"de": "PASSED: {pair}",
"en": "PASSED: {pair}",
"ru": "PASSED: {pair}",
"zh": "PASSED: {pair}"
},
"PR number must be an integer, got: {pr_number}": {
"bg": "PR number must be an integer, got: {pr_number}",
"de": "PR number must be an integer, got: {pr_number}",
"en": "PR number must be an integer, got: {pr_number}",
"ru": "PR number must be an integer, got: {pr_number}",
"zh": "PR number must be an integer, got: {pr_number}"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
},
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"en": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
},
"Published to Gitea PyPI registry.": {
"bg": "Публикувано в Gitea PyPI registry.",
"de": "In der Gitea PyPI-Registry veröffentlicht.",
"en": "Published to Gitea PyPI registry.",
"ru": "Опубликовано в Gitea PyPI registry.",
"zh": "已发布到 Gitea PyPI registry。"
},
"Published to PyPI.": {
"bg": "Публикувано в PyPI.",
"de": "In PyPI veröffentlicht.",
"en": "Published to PyPI.",
"ru": "Опубликовано в PyPI.",
"zh": "已发布到 PyPI。"
},
"Pushed release commit to master.": {
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
"en": "Pushed release commit to master.",
"ru": "Pushed release commit to master.",
"zh": "Pushed release commit to master."
},
"Rebased and pushed. Retrying merge...": {
"bg": "Rebased and pushed. Retrying merge...",
"de": "Rebased and pushed. Retrying merge...",
"en": "Rebased and pushed. Retrying merge...",
"ru": "Rebased and pushed. Retrying merge...",
"zh": "Rebased and pushed. Retrying merge..."
},
"Release creation failed: {error}": {
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
"en": "Release creation failed: {error}",
"ru": "Release creation failed: {error}",
"zh": "Release creation failed: {error}"
},
"Release must be run on master, currently on '{branch}'.": {
"bg": "Release must be run on master, currently on '{branch}'.",
"de": "Release must be run on master, currently on '{branch}'.",
"en": "Release must be run on master, currently on '{branch}'.",
"ru": "Release must be run on master, currently on '{branch}'.",
"zh": "Release must be run on master, currently on '{branch}'."
},
"Repo must be in 'owner/name' format, got: {repo}": {
"bg": "Repo must be in 'owner/name' format, got: {repo}",
"de": "Repo must be in 'owner/name' format, got: {repo}",
"en": "Repo must be in 'owner/name' format, got: {repo}",
"ru": "Repo must be in 'owner/name' format, got: {repo}",
"zh": "Repo must be in 'owner/name' format, got: {repo}"
},
"Repository configuration complete.": {
"bg": "Конфигурирането на хранилището е завършено.",
"de": "Repository-Konfiguration abgeschlossen.",
"en": "Repository configuration complete.",
"ru": "Конфигурация репозитория завершена.",
"zh": "仓库配置完成。"
},
"Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}",
"en": "Roles directory not found: {path}",
"ru": "Roles directory not found: {path}",
"zh": "Roles directory not found: {path}"
},
"Runner index {index} out of range (0..{max})": {
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
"en": "Runner index {index} out of range (0..{max})",
"ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
},
"Running lint checks...": {
"bg": "Running lint checks...",
"de": "Running lint checks...",
"en": "Running lint checks...",
"ru": "Running lint checks...",
"zh": "Running lint checks..."
},
"Running tests...": {
"bg": "Running tests...",
"de": "Running tests...",
"en": "Running tests...",
"ru": "Running tests...",
"zh": "Running tests..."
},
"Running: {scenario} on {platform}": {
"bg": "Running: {scenario} on {platform}",
"de": "Running: {scenario} on {platform}",
"en": "Running: {scenario} on {platform}",
"ru": "Running: {scenario} on {platform}",
"zh": "Running: {scenario} on {platform}"
},
"Skipping commit push — no staged changes.": {
"bg": "Skipping commit push — no staged changes.",
"de": "Skipping commit push — no staged changes.",
"en": "Skipping commit push — no staged changes.",
"ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes."
},
"Starting Docker daemon...": {
"bg": "Starting Docker daemon...",
"de": "Docker-Daemon wird gestartet...",
"en": "Starting Docker daemon...",
"ru": "Запуск Docker-демона...",
"zh": "正在启动 Docker 守护进程..."
},
"Syncing {count} documentation pages to wiki...": {
"bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...",
"en": "Syncing {count} documentation pages to wiki...",
"ru": "Syncing {count} documentation pages to wiki...",
"zh": "Syncing {count} documentation pages to wiki..."
},
"Tag consistency check failed.": {
"bg": "Tag consistency check failed.",
"de": "Tag consistency check failed.",
"en": "Tag consistency check failed.",
"ru": "Tag consistency check failed.",
"zh": "Tag consistency check failed."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"en": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"ru": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"zh": "Tag v{version} already existed. Publish workflow should already have been triggered."
},
"Tag {tag} already exists and points to HEAD. Skipping creation.": {
"bg": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"de": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"en": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"ru": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"zh": "Tag {tag} already exists and points to HEAD. Skipping creation."
},
"Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": {
"bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details."
},
"Task ID: {task_id}": {
"bg": "Task ID: {task_id}",
"de": "Task ID: {task_id}",
"en": "Task ID: {task_id}",
"ru": "Task ID: {task_id}",
"zh": "Task ID: {task_id}"
},
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
"bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"en": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
},
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
"bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
},
"Tests passed.": {
"bg": "Tests passed.",
"de": "Tests passed.",
"en": "Tests passed.",
"ru": "Tests passed.",
"zh": "Tests passed."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit)."
},
"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.": {
"bg": "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.",
"de": "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.",
"en": "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.",
"ru": "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.",
"zh": "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."
},
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
},
"Updated version in {init}": {
"bg": "Updated version in {init}",
"de": "Updated version in {init}",
"en": "Updated version in {init}",
"ru": "Updated version in {init}",
"zh": "Updated version in {init}"
},
"Updated {changelog_file}": {
"bg": "Updated {changelog_file}",
"de": "Updated {changelog_file}",
"en": "Updated {changelog_file}",
"ru": "Updated {changelog_file}",
"zh": "Updated {changelog_file}"
},
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
},
"Version file: {file}": {
"bg": "Version file: {file}",
"de": "Version file: {file}",
"en": "Version file: {file}",
"ru": "Version file: {file}",
"zh": "Version file: {file}"
},
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
},
"WARNING: --skip-tests passed — skipping test verification.": {
"bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "WARNING: --skip-tests passed — skipping test verification.",
"en": "WARNING: --skip-tests passed — skipping test verification.",
"ru": "WARNING: --skip-tests passed — skipping test verification.",
"zh": "WARNING: --skip-tests passed — skipping test verification."
},
"Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.",
"en": "Warning: could not fetch tags from origin.",
"ru": "Warning: could not fetch tags from origin.",
"zh": "Warning: could not fetch tags from origin."
},
"Wiki integrity check failed — {count} issue(s)": {
"bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)",
"en": "Wiki integrity check failed — {count} issue(s)",
"ru": "Wiki integrity check failed — {count} issue(s)",
"zh": "Wiki integrity check failed — {count} issue(s)"
},
"Wiki verification failed — {failures} page(s) empty or mismatched": {
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
"en": "Wiki verification failed — {failures} page(s) empty or mismatched",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
},
"[dry-run] Would commit: release: v{version}": {
"bg": "[dry-run] Would commit: release: v{version}",
"de": "[dry-run] Would commit: release: v{version}",
"en": "[dry-run] Would commit: release: v{version}",
"ru": "[dry-run] Would commit: release: v{version}",
"zh": "[dry-run] Would commit: release: v{version}"
},
"[dry-run] Would create tag: v{version}": {
"bg": "[dry-run] Would create tag: v{version}",
"de": "[dry-run] Would create tag: v{version}",
"en": "[dry-run] Would create tag: v{version}",
"ru": "[dry-run] Would create tag: v{version}",
"zh": "[dry-run] Would create tag: v{version}"
},
"[dry-run] Would create tag: {tag}": {
"bg": "[dry-run] Would create tag: {tag}",
"de": "[dry-run] Would create tag: {tag}",
"en": "[dry-run] Would create tag: {tag}",
"ru": "[dry-run] Would create tag: {tag}",
"zh": "[dry-run] Would create tag: {tag}"
},
"[dry-run] Would push commit to master": {
"bg": "[dry-run] Would push commit to master",
"de": "[dry-run] Would push commit to master",
"en": "[dry-run] Would push commit to master",
"ru": "[dry-run] Would push commit to master",
"zh": "[dry-run] Would push commit to master"
},
"[dry-run] Would sync page: {title} ({chars} chars)": {
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
"en": "[dry-run] Would sync page: {title} ({chars} chars)",
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
},
"[dry-run] Would update {changelog_file}": {
"bg": "[dry-run] Would update {changelog_file}",
"de": "[dry-run] Would update {changelog_file}",
"en": "[dry-run] Would update {changelog_file}",
"ru": "[dry-run] Would update {changelog_file}",
"zh": "[dry-run] Would update {changelog_file}"
},
"[dry-run] Would update {init}": {
"bg": "[dry-run] Would update {init}",
"de": "[dry-run] Would update {init}",
"en": "[dry-run] Would update {init}",
"ru": "[dry-run] Would update {init}",
"zh": "[dry-run] Would update {init}"
},
"active": {
"bg": "активен",
"de": "aktiv",
"en": "active",
"ru": "активен",
"zh": "活跃"
},
"completed": {
"bg": "завършен",
"de": "abgeschlossen",
"en": "completed",
"ru": "завершён",
"zh": "已完成"
},
"failed": {
"bg": "неуспешен",
"de": "fehlgeschlagen",
"en": "failed",
"ru": "неудачный",
"zh": "失败"
},
"git command failed ({cmd}): {stderr}": {
"bg": "git command failed ({cmd}): {stderr}",
"de": "git command failed ({cmd}): {stderr}",
"en": "git command failed ({cmd}): {stderr}",
"ru": "git command failed ({cmd}): {stderr}",
"zh": "git command failed ({cmd}): {stderr}"
},
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": {
"bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history."
},
"git-cliff returned empty version.": {
"bg": "git-cliff returned empty version.",
"de": "git-cliff returned empty version.",
"en": "git-cliff returned empty version.",
"ru": "git-cliff returned empty version.",
"zh": "git-cliff returned empty version."
},
"git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": {
"bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)."
},
"in_progress": {
"bg": "в процес",
"de": "in Bearbeitung",
"en": "in progress",
"ru": "в процессе",
"zh": "进行中"
},
"inactive": {
"bg": "неактивен",
"de": "inaktiv",
"en": "inactive",
"ru": "неактивен",
"zh": "未激活"
},
"mapping.json keys and values must be strings, got {k}={v}": {
"bg": "mapping.json keys and values must be strings, got {k}={v}",
"de": "mapping.json keys and values must be strings, got {k}={v}",
"en": "mapping.json keys and values must be strings, got {k}={v}",
"ru": "mapping.json keys and values must be strings, got {k}={v}",
"zh": "mapping.json keys and values must be strings, got {k}={v}"
},
"mapping.json must be a dict of file-path -> page-title, got {type}": {
"bg": "mapping.json must be a dict of file-path -> page-title, got {type}",
"de": "mapping.json must be a dict of file-path -> page-title, got {type}",
"en": "mapping.json must be a dict of file-path -> page-title, got {type}",
"ru": "mapping.json must be a dict of file-path -> page-title, got {type}",
"zh": "mapping.json must be a dict of file-path -> page-title, got {type}"
},
"pending": {
"bg": "в очакване",
"de": "ausstehend",
"en": "pending",
"ru": "ожидает",
"zh": "待处理"
},
"unknown": {
"bg": "неизвестен",
"de": "unbekannt",
"en": "unknown",
"ru": "неизвестно",
"zh": "未知"
},
"{file} already exists. Use --force to overwrite.": {
"bg": "{file} already exists. Use --force to overwrite.",
"de": "{file} already exists. Use --force to overwrite.",
"en": "{file} already exists. Use --force to overwrite.",
"ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite."
}
}
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="20" role="img"
aria-label="tests: 2310 passing">
<title>tests: 2310 passing</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="139" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="45" height="20" fill="#555"/>
<rect x="45" width="94" height="20" fill="#4c1"/>
<rect width="139" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="22" y="14">tests</text>
<text x="92" y="14">2310 passing</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

View File
View File
+652
View File
@@ -0,0 +1,652 @@
"""Unit tests for api_clients module."""
import http
from unittest.mock import MagicMock, patch
import pytest
import requests
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
from devx.config import (
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
VIKUNJA_PROJECT_ID,
)
from devx.exceptions import APIError
# Local test config — TEST_BP_CONFIG moved to configure_repo.py defaults in devx
TEST_BP_CONFIG: dict[str, object] = {
"branch_name": "master",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": [],
"enable_status_check": True,
"status_check_contexts": ["CI / quality (pull_request)"],
"required_approvals": 0,
}
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
mock = MagicMock()
if json_data is not None:
mock.json.return_value = json_data
if raise_on_status:
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
return mock
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
"""Create an HTTPError with a proper response attached (for _parse_error)."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = {"message": message or str(status_code)}
err = requests.HTTPError(f"{status_code} {message}", response=resp)
return err
class TestParseError:
def test_json_parse_fallback(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
mock_response.json = MagicMock(side_effect=ValueError("not json"))
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
status, message = _parse_error(err)
assert status == http.HTTPStatus.BAD_GATEWAY
assert str(http.HTTPStatus.BAD_GATEWAY) in message
def test_no_response(self) -> None:
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
status, message = _parse_error(err)
assert status == 0
assert "connection failed" in message
class TestGiteaClient:
def test_init_sets_headers(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._base_url == "https://git.example.com"
assert client._session.headers["Authorization"] == "token tok"
assert client._session.headers["Content-Type"] == "application/json"
def test_url_constructs_path(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_url_strips_trailing_slash(self) -> None:
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_list_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
result = client.list_labels()
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_list_labels_raises_api_error(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
with pytest.raises(APIError):
client.list_labels()
def test_http_error_json_parse_fallback(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
# Make json() itself raise so the except block in _parse_error is hit
mock_response.json = MagicMock(side_effect=ValueError("not json"))
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
client._session.request = MagicMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
client.list_labels()
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
def test_create_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
assert result["name"] == "ready-to-merge"
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
)
def test_ensure_label_creates_when_not_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[])
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_ensure_label_returns_none_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
client.create_label = MagicMock()
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is None
client.create_label.assert_not_called()
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response(
[
{"id": 1, "branch_name": "master"},
{"id": 2, "branch_name": "develop"},
]
)
)
result = client.list_branch_protections()
assert len(result) == 2
assert result[0]["branch_name"] == "master"
def test_create_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
result = client.create_branch_protection(TEST_BP_CONFIG)
assert result["id"] == 3
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/branch_protections",
timeout=DEFAULT_TIMEOUT,
json=TEST_BP_CONFIG,
)
def test_update_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
resp = {"branch_name": "master", "required_approvals": 2}
client._session.request = MagicMock(return_value=_mock_response(resp))
update = {"required_approvals": 2}
result = client.update_branch_protection("master", update)
assert result["required_approvals"] == 2
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo/branch_protections/master",
timeout=DEFAULT_TIMEOUT,
json=update,
)
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[])
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["id"] == 1
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
def test_ensure_branch_protection_updates_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}])
client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["required_approvals"] == 1
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.merge_pr(1, "fix: bug")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/1/merge",
timeout=DEFAULT_TIMEOUT,
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
result = client.get_pr_labels(5)
assert result == [{"name": "ready-to-merge"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/5/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_get_commit_status(self) -> None:
"""Uses combined status endpoint (/status, not /statuses)."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
)
result = client.get_commit_status("abc123")
assert result == [{"context": "CI / quality", "status": "success"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/commits/abc123/status",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
result = client.get_pr(7)
assert result["number"] == 7
assert result["head"]["sha"] == "abc123"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
)
result = client.get_pr_files(7)
assert len(result) == 1
assert result[0]["filename"] == "src/main.py"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/files",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_commits(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
)
result = client.get_pr_commits(7)
assert len(result) == 1
assert result[0]["commit"]["message"] == "fix: bug"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/commits",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
result = client.get_pr_reviews(7)
assert len(result) == 1
assert result[0]["state"] == "APPROVED"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"}))
result = client.create_issue(title="bug", body="description", labels=[1])
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description", "labels": [1]},
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue_no_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"}))
result = client.create_issue(title="bug", body="description")
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description"},
timeout=DEFAULT_TIMEOUT,
)
def test_create_review_comment(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
result = client.create_review(7, event="COMMENT", body="Looks good")
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "COMMENT", "body": "Looks good"},
)
def test_create_review_approve_maps_to_approved(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
result = client.create_review(7, event="APPROVE", body="Good work")
assert result["id"] == 44
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "APPROVED", "body": "Good work"},
)
def test_create_review_with_inline_comments(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
)
def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
settings = {"default_delete_branch_after_merge": True}
result = client.update_repo_settings(settings)
assert result["default_delete_branch_after_merge"] is True
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo",
timeout=DEFAULT_TIMEOUT,
json=settings,
)
def test_create_release(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
client.create_release("v1.0.0")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/releases",
timeout=DEFAULT_TIMEOUT,
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
)
def test_get_release_by_tag_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
result = client.get_release_by_tag("v1.0.0")
assert result is not None
assert result["id"] == 1
def test_get_release_by_tag_not_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
mock_resp.status_code = 404
client._session.request = MagicMock(return_value=mock_resp)
result = client.get_release_by_tag("v9.9.9")
assert result is None
def test_create_release_idempotent_existing(self) -> None:
"""If release already exists, should return it without creating a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
client._session.request = MagicMock(return_value=existing_response)
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 42
# Should only call GET (check), not POST (create)
assert client._session.request.call_count == 1
assert client._session.request.call_args[0][0] == "GET"
def test_create_release_idempotent_new(self) -> None:
"""If release doesn't exist, should create it."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found_resp = MagicMock()
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
not_found_resp.status_code = 404
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 1
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
"""Should retry on 429 rate limit with exponential backoff."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
rate_limited = MagicMock()
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 3
assert mock_sleep.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""Should retry on 503 service unavailable."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
"""Should NOT retry on 404 — it's not a transient error."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
client._session.request = MagicMock(return_value=not_found)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 404
assert client._session.request.call_count == 1
mock_sleep.assert_not_called()
@patch("devx.api_clients.time.sleep")
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""Should retry on connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent 503."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep")
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestVikunjaClient:
def test_init_sets_headers(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
assert client._base_url == "https://work.example.com"
assert client._session.headers["Authorization"] == "Bearer tok"
def test_list_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "identifier": "DEVX-19", "project_id": VIKUNJA_PROJECT_ID}])
)
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks",
timeout=DEFAULT_TIMEOUT,
params={"per_page": DEFAULT_PER_PAGE},
)
def test_list_project_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "DEVX-19"}]))
result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks",
timeout=DEFAULT_TIMEOUT,
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
)
def test_get_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 292, "identifier": "DEVX-32", "title": "Some task"})
)
result = client.get_task(292)
assert result["identifier"] == "DEVX-32"
assert result["title"] == "Some task"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/292",
timeout=DEFAULT_TIMEOUT,
)
def test_post_comment(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.post_comment(42, "<p>hi</p>")
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
json={"comment": "<p>hi</p>"},
)
def test_update_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.update_task(42, done=True)
client._session.request.assert_called_once_with(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"done": True},
)
@patch("devx.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError):
client.list_tasks()
def test_http_error_no_response(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = err
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert "connection failed" in str(exc_info.value)
@patch("devx.api_clients.time.sleep")
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should also retry on 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should retry on connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep")
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
assert _is_retryable(requests.ConnectionError("refused")) is True
def test_timeout_is_retryable(self) -> None:
assert _is_retryable(requests.Timeout("timed out")) is True
def test_429_is_retryable(self) -> None:
err = _mock_http_error(429, "rate limited")
assert _is_retryable(err) is True
def test_404_is_not_retryable(self) -> None:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
def test_generic_exception_is_not_retryable(self) -> None:
assert _is_retryable(ValueError("oops")) is False
+381
View File
@@ -0,0 +1,381 @@
"""Unit tests for scripts/ci/auto_merge.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.auto_merge import (
extract_conventional_msg,
extract_task_id,
main,
read_taskid,
run_cmd,
validate_pr_title,
validate_pr_title_matches_vikunja,
)
from devx.exceptions import APIError
# -- read_taskid --
class TestReadTaskid:
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
assert read_taskid("some-branch") == "DEVX-60"
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("feature-branch") == ""
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-42-test") == "DEVX-42"
# -- extract_task_id (legacy fallback) --
class TestExtractTaskId:
def test_extracts_from_branch(self) -> None:
assert extract_task_id("DEVX-19-fix-bug") == "DEVX-19"
assert extract_task_id("DEVX-123") == "DEVX-123"
def test_returns_empty_when_no_match(self) -> None:
assert extract_task_id("feature-branch") == ""
# -- validate_pr_title --
class TestValidatePrTitle:
def test_valid_title(self) -> None:
validate_pr_title("DEVX-19: Add new feature", "DEVX-19")
def test_missing_colon(self) -> None:
with pytest.raises(click.ClickException, match="format"):
validate_pr_title("DEVX-19 Add new feature", "DEVX-19")
def test_task_id_mismatch(self) -> None:
with pytest.raises(click.ClickException, match="mismatch"):
validate_pr_title("DEVX-20: Add feature", "DEVX-19")
def test_no_task_id_in_title(self) -> None:
with pytest.raises(click.ClickException, match="format"):
validate_pr_title("Add new feature", "DEVX-19")
# -- validate_pr_title_matches_vikunja --
class TestValidatePrTitleMatchesVikunja:
@patch.dict("os.environ", {}, clear=True)
def test_raises_when_no_token(self) -> None:
"""Should raise ClickException when VIKUNJA_TOKEN is not set."""
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN is not set"):
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_matches(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "DEVX-19", "title": "Add new feature"},
]
mock_client_cls.return_value = mock_client
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_mismatch_raises(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "DEVX-19", "title": "Different title"},
]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="does not match"):
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_task_not_found_raises(self, mock_client_cls: MagicMock) -> None:
"""When Vikunja task is not found and token is set, raises ClickException."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_task_found_on_second_page(self, mock_client_cls: MagicMock) -> None:
"""Pagination: task found on page 2."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"DEVX-{i}", "title": f"Title {i}"} for i in range(50)]
page2 = [{"id": 100, "identifier": "DEVX-99", "title": "Found me"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
# Should not raise — title matches
validate_pr_title_matches_vikunja("DEVX-99: Found me", "DEVX-99")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_task_not_found_partial_page_raises(self, mock_client_cls: MagicMock) -> None:
"""Pagination stops when page has fewer than DEFAULT_PER_PAGE results. Task not found raises."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "DEVX-1", "title": "Title 1"},
]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
# -- extract_conventional_msg --
class TestExtractConventionalMsg:
def test_finds_conventional(self) -> None:
commits = [
{"commit": {"message": "fix: resolve timeout"}},
{"commit": {"message": "merge branch"}},
]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_finds_latest_conventional(self) -> None:
commits = [
{"commit": {"message": "merge branch"}},
{"commit": {"message": "feat: add feature"}},
]
assert extract_conventional_msg(commits) == "feat: add feature"
def test_falls_back_to_newest(self) -> None:
commits = [
{"commit": {"message": "random message"}},
]
assert extract_conventional_msg(commits) == "random message"
def test_empty_commits(self) -> None:
assert extract_conventional_msg([]) == ""
def test_multiline_message(self) -> None:
commits = [
{"commit": {"message": "feat: add feature\n\nBody text."}},
]
assert extract_conventional_msg(commits) == "feat: add feature"
# -- run_cmd --
class TestRunCmd:
def test_success(self) -> None:
result = run_cmd(["echo", "hello"])
assert result.returncode == 0
def test_failure_raises(self) -> None:
with pytest.raises(click.ClickException, match="Command failed"):
run_cmd(["false"])
def test_failure_no_check(self) -> None:
result = run_cmd(["false"], check=False)
assert result.returncode != 0
# -- main (integration) --
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
# No .taskid file, no DEVX-N in branch name
runner = CliRunner()
result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "No task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"])
assert result.exit_code != 0
assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = [
APIError(405, "HEAD branch is behind master"),
None, # Second call succeeds
]
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
assert mock_client.merge_pr.call_count == 2
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(409, "Conflict")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
assert result.exit_code != 0
assert "PR number must be an integer" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
assert result.exit_code != 0
assert "owner/name" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
mock_run.side_effect = click.ClickException("git rebase failed")
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "rebase" in result.output.lower()
def test_main_module_block() -> None:
"""Test that the __main__ block can be executed."""
import devx.ci.auto_merge as am
with open(am.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(am.__dict__)
exec(compile(source, am.__file__, "exec"), namespace)
# Verify main is callable
assert callable(namespace["main"])
+294
View File
@@ -0,0 +1,294 @@
"""Unit tests for devx.tools.check_test_speed."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.check_test_speed import (
DEFAULT_MAX_SECONDS,
DEFAULT_MAX_SINGLE_SECONDS,
TEST_COMMAND,
check_per_test_speed,
check_speed,
cli,
parse_duration,
parse_per_test_durations,
run_tests,
)
class TestRunTests:
@patch("devx.tools.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()
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == TEST_COMMAND
assert call_kwargs.kwargs["capture_output"] is True
assert call_kwargs.kwargs["text"] is True
assert call_kwargs.kwargs["check"] is False
env = call_kwargs.kwargs["env"]
assert "--durations=0" in env["PYTEST_ADDOPTS"]
@patch("devx.tools.check_test_speed.subprocess.run")
def test_run_tests_preserves_existing_pytest_addopts(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
with patch.dict("os.environ", {"PYTEST_ADDOPTS": "-x"}, clear=False):
run_tests()
env = mock_run.call_args.kwargs["env"]
assert "--durations=0" in env["PYTEST_ADDOPTS"]
assert "-x" in env["PYTEST_ADDOPTS"]
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 TestParsePerTestDurations:
def test_parses_call_lines(self) -> None:
output = "0.01s call tests/test_foo.py::test_bar\n"
durations = parse_per_test_durations(output)
assert len(durations) == 1
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
def test_parses_setup_and_teardown(self) -> None:
output = (
"0.02s setup tests/test_foo.py::test_bar\n"
"0.01s call tests/test_foo.py::test_bar\n"
"0.00s teardown tests/test_foo.py::test_bar\n"
)
durations = parse_per_test_durations(output)
assert len(durations) == 3
names = [d[0] for d in durations]
assert "tests/test_foo.py::test_bar" in names
def test_sorted_slowest_first(self) -> None:
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
durations = parse_per_test_durations(output)
assert durations[0][1] >= durations[1][1]
assert durations[0][1] == 0.50
def test_empty_output(self) -> None:
assert parse_per_test_durations("") == []
def test_ignores_non_duration_lines(self) -> None:
output = "Some random line\n234 passed in 0.70s\n"
assert parse_per_test_durations(output) == []
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
class TestCheckPerTestSpeed:
def test_no_violations_when_all_fast(self) -> None:
durations = [("test_a", 0.1), ("test_b", 0.2)]
assert check_per_test_speed(durations, 0.5) == []
def test_violation_when_test_exceeds_limit(self) -> None:
durations = [("test_slow", 0.6), ("test_fast", 0.1)]
violations = check_per_test_speed(durations, 0.5)
assert len(violations) == 1
assert "test_slow" in violations[0]
assert "0.60s" in violations[0]
def test_multiple_violations(self) -> None:
durations = [("test_a", 0.7), ("test_b", 0.6), ("test_c", 0.1)]
violations = check_per_test_speed(durations, 0.5)
assert len(violations) == 2
def test_exact_limit_passes(self) -> None:
durations = [("test_a", 0.5)]
assert check_per_test_speed(durations, 0.5) == []
def test_empty_durations(self) -> None:
assert check_per_test_speed([], 0.5) == []
def test_main_module_block() -> None:
import devx.tools.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("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_successful_run(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "1.50s" in result.output
assert "under 10.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)
mock_parse_per.assert_called_once()
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
def test_slow_total_exits(
self,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 15.0
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "too slow" in result.output.lower()
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_per_test_violation_exits(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
mock_parse_per.return_value = [("test_slow", 0.8)]
mock_check_per.return_value = ["Test 'test_slow' took 0.80s (limit: 0.5s)."]
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "Per-test speed check FAILED" in result.output
assert "test_slow" in result.output
@patch("devx.tools.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("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_custom_max_seconds(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
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)
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_disable_per_test_check(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 1.0
runner = CliRunner()
result = runner.invoke(cli, ["--max-single-seconds", "0"])
assert result.exit_code == 0
mock_parse_per.assert_not_called()
mock_check_per.assert_not_called()
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_custom_max_single_seconds(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 1.0
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
assert result.exit_code == 0
mock_check_per.assert_called_once_with([], 1.0)
+375
View File
@@ -0,0 +1,375 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from click.testing import CliRunner
import devx.ci.check_translations as check_translations
class TestExtractKeys:
def test_extracts_underscore_calls(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('from devx.i18n import _\nprint(_("Hello world"))\n')
keys = check_translations.extract_keys(f)
assert "Hello world" in keys
def test_extracts_wrapper_calls(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('@_handle_errors("Update failed: {error}")\ndef foo(): pass\n')
keys = check_translations.extract_keys(f)
assert "Update failed: {error}" in keys
def test_ignores_non_string_args(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('x = "key"\n_(x)\n')
keys = check_translations.extract_keys(f)
assert keys == set()
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text("def broken(:\n")
keys = check_translations.extract_keys(f)
assert keys == set()
class TestCheckTranslationSet:
def test_all_good(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert not result.errors
assert not result.warnings
def test_missing_key(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Missing")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Other": {"en": "Other"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing key" in e for e in result.errors)
def test_dead_key(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Used")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Dead key" in e for e in result.errors)
def test_missing_language(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing languages" in e for e in result.errors)
def test_missing_translations_file(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "nonexistent.json"
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("not found" in e for e in result.errors)
class TestMain:
def test_passes_on_clean_repo(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The actual repo should pass when there are no errors."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
def test_errors_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Errors should cause exit code 1."""
error_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
errors=["Dead key: 'bar'"],
)
monkeypatch.setattr(check_translations, "check_translation_set", lambda name, src, trans: error_result)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should fail with exit code 1 when errors are found."""
error_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
errors=["Missing key: 'foo'"],
)
ok_result = check_translations.TranslationCheckResult(
name="devx2",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: error_result if name == "devx" else ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1
assert "FAIL" in result.output
def test_passes_no_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS:' message when no warnings."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS: All translations" in result.output
def test_translations_flag(self, tmp_path: Path) -> None:
"""--translations flag should check a specific file."""
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
(tmp_path / "mod.py").write_text('_("Hello")\n')
runner = CliRunner()
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
assert result.exit_code == 0
class TestPrintResult:
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "All good!" in captured.out
def test_prints_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
errors=["Missing key: 'foo'"],
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "ERROR" in captured.err
def test_prints_warnings(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
warnings=["Dead key: 'bar'"],
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "WARN" in captured.err
class TestDevxI18n:
"""Test the devx.i18n module."""
def test_english_default(self) -> None:
from devx.i18n import _
assert _("Running tests...") == "Running tests..."
def test_format_kwargs(self) -> None:
from devx.i18n import _
result = _("Comparing {base}..{head} ({count} files changed)", base="a", head="b", count=5)
assert "a..b" in result
assert "5 files" in result
def test_unknown_key_returns_key(self) -> None:
from devx.i18n import _
assert _("Nonexistent key 12345") == "Nonexistent key 12345"
def test_devx_lang_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "de")
import devx.i18n
importlib.reload(devx.i18n)
# "ERROR: REPO_TOKEN is not set." has a German translation
result = devx.i18n._("ERROR: REPO_TOKEN is not set.")
assert "FEHLER" in result
# Restore
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "xx")
import devx.i18n
importlib.reload(devx.i18n)
result = devx.i18n._("Running tests...")
assert result == "Running tests..." # Falls back to English
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
class TestTranslationsFile:
"""Test that the devx translations file is valid."""
def test_translations_exist(self) -> None:
trans = Path("src/devx/translations.json")
assert trans.exists()
data = json.loads(trans.read_text())
assert len(data) > 0
def test_all_keys_have_english(self) -> None:
trans = Path("src/devx/translations.json")
data = json.loads(trans.read_text())
for key, langs in data.items():
assert "en" in langs, f"Key {key!r} missing English translation"
class TestCollectKeys:
def test_skips_i18n_py(self, tmp_path: Path) -> None:
"""i18n.py should be skipped when collecting keys."""
(tmp_path / "i18n.py").write_text('_("should_not_appear")\n')
(tmp_path / "mod.py").write_text('_("should_appear")\n')
keys = check_translations.collect_keys(tmp_path)
assert "should_appear" in keys
assert "should_not_appear" not in keys
def test_non_default_dir_no_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should not include DYNAMIC_KEYS."""
(tmp_path / "mod.py").write_text('_("mykey")\n')
keys = check_translations.collect_keys(tmp_path)
assert "mykey" in keys
# Dynamic keys should NOT be present for non-default dirs
assert "completed" not in keys
assert "pending" not in keys
def test_default_dir_includes_dynamic_keys(self) -> None:
"""The default source dir should include DYNAMIC_KEYS."""
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
assert "completed" in keys
assert "pending" in keys
assert "in_progress" in keys
class TestMainCleanPass:
def test_passes_clean(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS:' message when no errors."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS:" in result.output
class TestI18nProjectTranslations:
"""Test devx.i18n._load_project_translations function."""
def test_loads_project_translations(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project translations from DEVX_TRANSLATIONS_PATH are loaded."""
trans_file = tmp_path / "extra.json"
trans_file.write_text(json.dumps({"Custom key": {"en": "Custom value"}}))
monkeypatch.setenv("DEVX_TRANSLATIONS_PATH", str(trans_file))
import importlib
import devx.i18n
importlib.reload(devx.i18n)
assert "Custom key" in devx.i18n.TRANSLATIONS
# Restore
monkeypatch.delenv("DEVX_TRANSLATIONS_PATH", raising=False)
importlib.reload(devx.i18n)
def test_nonexistent_path_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Nonexistent DEVX_TRANSLATIONS_PATH returns empty dict."""
monkeypatch.setenv("DEVX_TRANSLATIONS_PATH", "/nonexistent/path/file.json")
import importlib
import devx.i18n
importlib.reload(devx.i18n)
# Should still work with built-in translations
assert devx.i18n._("Running tests...") == "Running tests..."
monkeypatch.delenv("DEVX_TRANSLATIONS_PATH", raising=False)
importlib.reload(devx.i18n)
def test_invalid_json_returns_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Invalid JSON in DEVX_TRANSLATIONS_PATH returns empty dict."""
trans_file = tmp_path / "bad.json"
trans_file.write_text("{invalid json content")
monkeypatch.setenv("DEVX_TRANSLATIONS_PATH", str(trans_file))
import importlib
import devx.i18n
importlib.reload(devx.i18n)
# Should still work with built-in translations
assert devx.i18n._("Running tests...") == "Running tests..."
monkeypatch.delenv("DEVX_TRANSLATIONS_PATH", raising=False)
importlib.reload(devx.i18n)
+744
View File
@@ -0,0 +1,744 @@
"""Unit tests for devx.ci.classify_changes.
Tests cover:
- Glob matching (``_glob_to_regex``, ``_matches_glob``)
- Classifier config loading from pyproject.toml
- ChangeClassifier with layered rules (overrides, patterns, default)
- Tag system (orthogonal categories)
- Backward-compatible API (is_user_facing, is_workflow_only, classify_changes)
- Git helpers (get_changed_files, get_latest_tag, run_git)
- CLI (main with --quiet, --check, --github-output)
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
import devx.ci.classify_changes as classify_changes_mod
from devx.ci.classify_changes import (
DEFAULT_INFRASTRUCTURE,
ChangeClassifier,
ClassificationResult,
ClassifierConfig,
FileClassification,
_glob_to_regex,
_matches_glob,
classify_changes,
get_changed_files,
get_latest_tag,
has_user_facing_changes,
is_user_facing,
is_workflow_only,
main,
run_git,
)
# ---------------------------------------------------------------------------
# Glob matching tests
# ---------------------------------------------------------------------------
class TestGlobToRegex:
def test_double_star_matches_anything(self) -> None:
regex = _glob_to_regex(".gitea/**")
assert regex.match(".gitea/workflows/ci.yml")
assert regex.match(".gitea/actionlint.yaml")
assert regex.match(".gitea/a/b/c/d.yml")
assert not regex.match("tests/test_foo.py")
def test_double_star_in_middle(self) -> None:
"""** in the middle of a pattern matches any number of segments."""
regex = _glob_to_regex("src/**/test_*.py")
assert regex.match("src/test_foo.py")
assert regex.match("src/devx/test_cli.py")
assert regex.match("src/a/b/c/test_bar.py")
assert not regex.match("src/cli.py")
def test_single_star_matches_within_segment(self) -> None:
regex = _glob_to_regex("src/*/cli.py")
assert regex.match("src/devx/cli.py")
assert regex.match("src/pkg/cli.py")
assert not regex.match("src/devx/sub/cli.py")
def test_question_mark_matches_single_char(self) -> None:
regex = _glob_to_regex("file?.py")
assert regex.match("file1.py")
assert regex.match("fileA.py")
assert not regex.match("file12.py")
def test_literal_match(self) -> None:
regex = _glob_to_regex("Makefile")
assert regex.match("Makefile")
assert not regex.match("makefile")
def test_special_chars_escaped(self) -> None:
regex = _glob_to_regex("file.test.py")
assert regex.match("file.test.py")
assert not regex.match("fileXtest.py")
class TestMatchesGlob:
def test_double_star(self) -> None:
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/**")
assert _matches_glob("tests/unit/test_cli.py", "tests/**")
assert not _matches_glob("src/devx/cli.py", "tests/**")
def test_exact_match(self) -> None:
assert _matches_glob("Makefile", "Makefile")
assert _matches_glob("src/devx/__init__.py", "src/devx/__init__.py")
assert not _matches_glob("src/devx/cli.py", "src/devx/__init__.py")
def test_prefix_matching(self) -> None:
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/")
assert _matches_glob("scripts/ci/release.py", "scripts/")
assert not _matches_glob("tests/test_foo.py", "scripts/")
def test_single_star(self) -> None:
assert _matches_glob("src/devx/cli.py", "src/devx/*.py")
assert not _matches_glob("src/devx/sub/cli.py", "src/devx/*.py")
# ---------------------------------------------------------------------------
# ClassifierConfig tests
# ---------------------------------------------------------------------------
class TestClassifierConfig:
def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.devx.classify]\n"
'infrastructure = ["scripts/**"]\n'
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
'user_facing_overrides = ["docs/important.py"]\n'
"\n"
"[tool.devx.classify.tags]\n"
'ansible = ["ansible/**"]\n'
)
config = ClassifierConfig.from_pyproject(str(pyproject))
# Project-specific path is merged with defaults
assert "scripts/**" in config.infrastructure
assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
assert config.use_defaults is True
assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
assert config.user_facing_overrides == ["docs/important.py"]
assert config.tags == {"ansible": ["ansible/**"]}
def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**"]
assert "tests/**" not in config.infrastructure # no defaults
assert config.use_defaults is False
def test_from_pyproject_missing_file_returns_defaults(self) -> None:
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
assert config.use_defaults is True
def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "test"\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert "scripts/**" in config.infrastructure
assert ".gitea/**" in config.infrastructure # merged with defaults
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
def test_defaults_are_empty_for_bare_constructor(self) -> None:
"""ClassifierConfig() without from_pyproject has empty lists."""
config = ClassifierConfig()
assert config.infrastructure == []
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
assert config.use_defaults is True
def test_default_infrastructure_is_non_empty(self) -> None:
"""The framework ships with a curated default infrastructure list."""
assert len(DEFAULT_INFRASTRUCTURE) > 0
assert ".gitea/**" in DEFAULT_INFRASTRUCTURE
assert "tests/**" in DEFAULT_INFRASTRUCTURE
assert "docs/**" in DEFAULT_INFRASTRUCTURE
def test_default_infrastructure_covers_common_project_files(self) -> None:
"""DEFAULT_INFRASTRUCTURE must cover common project-level files
that are not part of the installed package.
This test prevents regression of the root cause of GRM-64
misclassification: 28 files (scripts/**, REVIEW_CHECKLIST.md)
were classified as user-facing because these patterns were
missing from the defaults.
"""
# Project documentation
assert "AGENTS.md" in DEFAULT_INFRASTRUCTURE
assert "README.md" in DEFAULT_INFRASTRUCTURE
assert "CHANGELOG.md" in DEFAULT_INFRASTRUCTURE
assert "TROUBLESHOOTING.md" in DEFAULT_INFRASTRUCTURE
assert "CONTRIBUTING.md" in DEFAULT_INFRASTRUCTURE
assert "CODE_OF_CONDUCT.md" in DEFAULT_INFRASTRUCTURE
assert "REVIEW_CHECKLIST.md" in DEFAULT_INFRASTRUCTURE
# Build tooling
assert "Makefile" in DEFAULT_INFRASTRUCTURE
assert "cliff.toml" in DEFAULT_INFRASTRUCTURE
assert "uv.lock" in DEFAULT_INFRASTRUCTURE
# Lint config
assert ".pre-commit-config.yaml" in DEFAULT_INFRASTRUCTURE
assert ".ruff.toml" in DEFAULT_INFRASTRUCTURE
assert ".ansible-lint" in DEFAULT_INFRASTRUCTURE
assert ".checkmake.ini" in DEFAULT_INFRASTRUCTURE
assert ".editorconfig" in DEFAULT_INFRASTRUCTURE
# Git config
assert ".gitignore" in DEFAULT_INFRASTRUCTURE
assert ".gitattributes" in DEFAULT_INFRASTRUCTURE
# Agent config
assert ".devin/**" in DEFAULT_INFRASTRUCTURE
# ---------------------------------------------------------------------------
# ChangeClassifier tests
# ---------------------------------------------------------------------------
class TestChangeClassifier:
def _make_classifier(self, **kwargs: object) -> ChangeClassifier:
"""Create a classifier with explicit config (no pyproject.toml needed)."""
config = ClassifierConfig(**kwargs) # type: ignore[arg-type]
return ChangeClassifier(config)
def test_infrastructure_pattern_matches(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"])
fc = classifier.classify_file(".gitea/workflows/ci.yml")
assert not fc.is_user_facing
assert "infrastructure" in fc.matched_rule
def test_unknown_file_defaults_to_user_facing(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**"])
fc = classifier.classify_file("src/devx/cli.py")
assert fc.is_user_facing
assert fc.matched_rule is None
assert "default" in fc.reason.lower()
def test_infrastructure_override(self) -> None:
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
infrastructure_overrides=["src/devx/__init__.py"],
)
fc = classifier.classify_file("src/devx/__init__.py")
assert not fc.is_user_facing
assert fc.matched_rule == "infrastructure_overrides"
def test_user_facing_override_beats_infrastructure(self) -> None:
"""User-facing overrides have highest priority (safety)."""
classifier = self._make_classifier(
infrastructure=["tests/**"],
user_facing_overrides=["tests/test_public_api.py"],
)
fc = classifier.classify_file("tests/test_public_api.py")
assert fc.is_user_facing
assert fc.matched_rule == "user_facing_overrides"
def test_user_facing_override_beats_infrastructure_override(self) -> None:
"""User-facing overrides beat infrastructure overrides (safety first)."""
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
infrastructure_overrides=["src/devx/__init__.py"],
user_facing_overrides=["src/devx/__init__.py"],
)
fc = classifier.classify_file("src/devx/__init__.py")
assert fc.is_user_facing
def test_tags_are_computed(self) -> None:
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**", ".ansible-lint"], "docs": ["docs/**"]},
)
fc = classifier.classify_file("ansible/tasks/main.yml")
assert "ansible" in fc.tags
assert "docs" not in fc.tags
def test_tags_orthogonal_to_classification(self) -> None:
"""A file can be infrastructure AND tagged."""
classifier = self._make_classifier(
infrastructure=[".gitea/**", "docs/**"],
tags={"docs": ["docs/**"]},
)
fc = classifier.classify_file("docs/index.md")
assert not fc.is_user_facing # infrastructure
assert "docs" in fc.tags # also tagged
def test_classify_multiple_files(self) -> None:
classifier = self._make_classifier(
infrastructure=[".gitea/**", "tests/**"],
infrastructure_overrides=["src/devx/__init__.py"],
tags={"ansible": ["ansible/**"]},
)
files = [
"src/devx/cli.py",
".gitea/workflows/ci.yml",
"src/devx/__init__.py",
"ansible/tasks/main.yml",
"tests/test_foo.py",
]
result = classifier.classify(files)
assert "src/devx/cli.py" in result.user_facing
assert "ansible/tasks/main.yml" in result.user_facing
assert ".gitea/workflows/ci.yml" in result.infrastructure
assert "src/devx/__init__.py" in result.infrastructure
assert "tests/test_foo.py" in result.infrastructure
assert result.has_user_facing
assert result.has_tag("ansible")
assert "ansible/tasks/main.yml" in result.tags["ansible"]
def test_classify_empty(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**"])
result = classifier.classify([])
assert not result.has_user_facing
assert result.user_facing == []
assert result.infrastructure == []
def test_reason_is_human_readable(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**"])
fc = classifier.classify_file(".gitea/workflows/ci.yml")
assert ".gitea/**" in fc.reason
fc2 = classifier.classify_file("src/devx/cli.py")
assert "default" in fc2.reason.lower() or "user-facing" in fc2.reason.lower()
class TestClassificationResult:
def test_has_user_facing(self) -> None:
result = ClassificationResult(user_facing=["src/cli.py"])
assert result.has_user_facing
def test_has_user_facing_empty(self) -> None:
result = ClassificationResult()
assert not result.has_user_facing
def test_has_tag(self) -> None:
result = ClassificationResult(tags={"ansible": ["ansible/tasks/main.yml"]})
assert result.has_tag("ansible")
assert not result.has_tag("docs")
# ---------------------------------------------------------------------------
# Backward-compatible API tests
# ---------------------------------------------------------------------------
class TestBackwardCompatibleAPI:
def test_is_workflow_only_with_config(self) -> None:
"""is_workflow_only uses the config-driven classifier by default."""
with patch.object(classify_changes_mod, "_get_classifier") as mock:
classifier = MagicMock()
classifier.classify_file.return_value = FileClassification(
path=".gitea/workflows/ci.yml",
is_user_facing=False,
reason="test",
matched_rule="infrastructure: .gitea/**",
)
mock.return_value = classifier
assert is_workflow_only(".gitea/workflows/ci.yml") is True
def test_is_user_facing_with_config(self) -> None:
with patch.object(classify_changes_mod, "_get_classifier") as mock:
classifier = MagicMock()
classifier.classify_file.return_value = FileClassification(
path="src/devx/cli.py",
is_user_facing=True,
reason="test",
matched_rule=None,
)
mock.return_value = classifier
assert is_user_facing("src/devx/cli.py") is True
def test_legacy_patterns_mode(self) -> None:
"""is_workflow_only with explicit patterns uses legacy prefix matching."""
patterns = frozenset([".gitea/", "tests/"])
assert is_workflow_only(".gitea/workflows/ci.yml", patterns) is True
assert is_workflow_only("tests/test_foo.py", patterns) is True
assert is_workflow_only("src/devx/cli.py", patterns) is False
def test_classify_changes_with_config(self) -> None:
with patch.object(classify_changes_mod, "_get_classifier") as mock:
classifier = MagicMock()
classifier.classify.return_value = ClassificationResult(
user_facing=["src/devx/cli.py"],
infrastructure=[".gitea/workflows/ci.yml"],
)
mock.return_value = classifier
result = classify_changes(["src/devx/cli.py", ".gitea/workflows/ci.yml"])
assert "src/devx/cli.py" in result["user_facing"]
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
def test_classify_changes_legacy_mode(self) -> None:
patterns = frozenset([".gitea/", "tests/"])
result = classify_changes([".gitea/ci.yml", "src/cli.py"], patterns)
assert ".gitea/ci.yml" in result["workflow_only"]
assert "src/cli.py" in result["user_facing"]
def test_has_user_facing_changes_with_config(self) -> None:
with (
patch.object(classify_changes_mod, "get_changed_files", return_value=["src/devx/cli.py"]),
patch.object(classify_changes_mod, "_get_classifier") as mock,
):
classifier = MagicMock()
classifier.classify.return_value = ClassificationResult(
user_facing=["src/devx/cli.py"],
)
mock.return_value = classifier
assert has_user_facing_changes("v0.1.0", "HEAD") is True
def test_has_user_facing_changes_legacy(self) -> None:
with patch.object(classify_changes_mod, "get_changed_files", return_value=[".gitea/ci.yml"]):
patterns = frozenset([".gitea/"])
assert has_user_facing_changes("v0.1.0", "HEAD", patterns) is False
# ---------------------------------------------------------------------------
# Git helper tests
# ---------------------------------------------------------------------------
class TestGetChangedFiles:
@patch("devx.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("devx.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 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("devx.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("devx.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"])
# ---------------------------------------------------------------------------
# CLI tests
# ---------------------------------------------------------------------------
class TestMain:
@patch("devx.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("devx.ci.classify_changes.get_changed_files", return_value=[])
@patch("devx.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("devx.ci.classify_changes.get_changed_files")
@patch("devx.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("devx.ci.classify_changes.get_changed_files")
@patch("devx.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/devx/cli.py", "docs/index.md"]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_default_mode_displays_tags(
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
) -> None:
"""Default mode shows tag files when tags are configured."""
mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Ansible files" in result.output
assert "ansible/tasks/main.yml" in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No tags found" in result.output
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
@patch("devx.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:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No changes" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
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("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = ["src/devx/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("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.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:
mock_changes.return_value = ["src/devx/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("devx.ci.classify_changes.get_changed_files")
@patch("devx.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:
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("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"])
assert result.exit_code == 0
assert "Ansible changes detected" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "nonexistent"])
assert result.exit_code != 0
assert "Unknown check category" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.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:
mock_changes.return_value = ["src/devx/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 _make_classifier_with_ansible(self) -> ChangeClassifier:
return ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**", "AGENTS.md"],
tags={"ansible": ["ansible/**"]},
)
)
@patch("devx.ci.classify_changes._get_classifier")
def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
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
@patch("devx.ci.classify_changes._get_classifier")
def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
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
@patch("devx.ci.classify_changes._get_classifier")
def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
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
@patch("devx.ci.classify_changes._get_classifier")
def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
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
@patch("devx.ci.classify_changes._get_classifier")
def test_no_tags_outputs_all_tags_true(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When no tags exist, only user-facing-changed is written."""
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={},
)
)
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 "user-facing-changed=true" in content
# No tag outputs since no tags are configured
assert "ansible-changed" not in content
+325
View File
@@ -0,0 +1,325 @@
"""Unit tests for devx.cli module."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.cli import cli
class TestCliGroups:
def test_cli_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "devx" in result.output
assert "ci" in result.output
assert "tools" in result.output
assert "molecule" in result.output
def test_ci_group_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "--help"])
assert result.exit_code == 0
assert "auto-merge" in result.output
assert "release" in result.output
assert "publish" in result.output
def test_tools_group_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "--help"])
assert result.exit_code == 0
assert "setup" in result.output
assert "install-tools" in result.output
assert "configure-repo" in result.output
def test_molecule_group_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "--help"])
assert result.exit_code == 0
assert "distribute" in result.output
assert "guard" in result.output
assert "all" in result.output
class TestCiCommands:
@patch("devx.cli._run_module")
def test_ci_auto_merge(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "auto-merge", "branch", "title", "repo", "1"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.auto_merge", ["branch", "title", "repo", "1"])
@patch("devx.cli._run_module")
def test_ci_check_translations(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "check-translations"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.check_translations", [])
@patch("devx.cli._run_module")
def test_ci_classify_changes(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "classify-changes", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.classify_changes", ["positional"])
@patch("devx.cli._run_module")
def test_ci_detect_release_commit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "detect-release-commit"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.detect_release_commit", [])
@patch("devx.cli._run_module")
def test_ci_discover_runners(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "discover-runners", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.discover_runners", ["positional"])
@patch("devx.cli._run_module")
def test_ci_doc_coverage(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "doc-coverage"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
@patch("devx.cli._run_module")
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "notify-failure", "job1"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.notify_failure", ["job1"])
@patch("devx.cli._run_module")
def test_ci_post_merge(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "post-merge", "DEVX-1"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.post_merge", ["DEVX-1"])
@patch("devx.cli._run_module")
def test_ci_pr_review(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "pr-review", "42"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.pr_review", ["42"])
@patch("devx.cli._run_module")
def test_ci_publish(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "publish", "v1.0.0", "owner/repo"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.publish", ["v1.0.0", "owner/repo"])
@patch("devx.cli._run_module")
def test_ci_push_badges(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "push-badges"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.push_badges", [])
@patch("devx.cli._run_module")
def test_ci_release(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "release", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.release", ["positional"])
@patch("devx.cli._run_module")
def test_ci_sync_wiki(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "sync-wiki"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.sync_wiki", [])
@patch("devx.cli._run_module")
def test_ci_validate_commit_msg(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "validate-commit-msg", "msg"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.validate_commit_msg", ["msg"])
class TestToolsCommands:
@patch("devx.cli._run_module")
def test_tools_check_test_speed(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "check-test-speed"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.check_test_speed", [])
@patch("devx.cli._run_module")
def test_tools_configure_repo(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "configure-repo", "myrepo"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.configure_repo", ["myrepo"])
@patch("devx.cli._run_module")
def test_tools_generate_badges(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "generate-badges"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_badges", [])
@patch("devx.cli._run_module")
def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "generate-cliff-config"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_cliff_config", [])
@patch("devx.cli._run_module")
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "install-checkmake"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.install_checkmake", [])
@patch("devx.cli._run_module")
def test_tools_install_tools(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "install-tools"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.install_tools", [])
@patch("devx.cli._run_module")
def test_tools_setup(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "setup"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.setup", [])
class TestMoleculeCommands:
@patch("devx.cli._run_module")
def test_molecule_distribute(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "distribute", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.distribute_molecule", ["positional"])
@patch("devx.cli._run_module")
def test_molecule_discover_runners(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "discover-runners"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
@patch("devx.cli._run_module")
def test_molecule_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "guard"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", [])
@patch("devx.cli._run_module")
def test_molecule_all(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "all"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.molecule_all", [])
class TestNewCiCommands:
@patch("devx.cli._run_module")
def test_ci_distribute_files(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "distribute-files", "--", "--pattern", "*.py"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
@patch("devx.cli._run_module")
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "integration-guard", "--", "-v"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.integration_guard", ["-v"])
class TestRunModule:
@patch("importlib.import_module")
def test_run_module_success(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_main = MagicMock()
mock_module.main = mock_main
mock_import.return_value = mock_module
_run_module("devx.ci.release", ["--dry-run"])
mock_import.assert_called_once_with("devx.ci.release")
mock_main.assert_called_once()
@patch("importlib.import_module")
def test_run_module_no_main_raises(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_module.main = None
mock_import.return_value = mock_module
with pytest.raises(SystemExit) as exc_info:
_run_module("devx.ci.nonexistent", [])
assert exc_info.value.code == 1
@patch("importlib.import_module")
def test_run_module_click_exception(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_main = MagicMock()
mock_main.side_effect = click.ClickException("test error")
mock_module.main = mock_main
mock_import.return_value = mock_module
with pytest.raises(SystemExit):
_run_module("devx.ci.release", [])
@patch("importlib.import_module")
def test_run_module_abort(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_main = MagicMock()
mock_main.side_effect = click.exceptions.Abort()
mock_module.main = mock_main
mock_import.return_value = mock_module
with pytest.raises(SystemExit) as exc_info:
_run_module("devx.ci.release", [])
assert exc_info.value.code == 1
@patch("importlib.import_module")
def test_run_module_click_command_object(self, mock_import: MagicMock) -> None:
"""When main is not callable (a click Command), uses .main() method."""
from devx.cli import _run_module
mock_module = MagicMock()
# Create a non-callable object with a .main method (like click.Command)
# callable() returns False only if __call__ is not defined
class NonCallableMain:
def __init__(self) -> None:
self.main = MagicMock()
non_callable = NonCallableMain()
assert not callable(non_callable)
mock_module.main = non_callable
mock_import.return_value = mock_module
_run_module("devx.ci.release", ["arg1"])
non_callable.main.assert_called_once()
class TestVersionOption:
def test_version_flag(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
+91
View File
@@ -0,0 +1,91 @@
"""Unit tests for config module constants."""
from devx.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
MAX_RETRIES,
REPO_OWNER,
RETRY_BACKOFF_BASE,
RETRY_STATUS_CODES,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
)
class TestConfigConstants:
def test_api_urls(self) -> None:
assert "api/v1" in GITEA_API_URL
assert "api/v1" in VIKUNJA_API_URL
def test_project_ids(self, monkeypatch: object) -> None:
"""VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set."""
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 6
# Restore module state
importlib.reload(cfg)
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner(self) -> None:
assert REPO_OWNER == "oblachno-oss"
def test_task_prefix(self) -> None:
assert TASK_PREFIX == "DEVX"
def test_task_id_re(self) -> None:
assert TASK_ID_RE.search("DEVX-1")
assert TASK_ID_RE.search("DEVX-123")
assert not TASK_ID_RE.search("DEVX-")
assert not TASK_ID_RE.search("other text")
def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
def test_retry_config(self) -> None:
assert MAX_RETRIES == 3
assert RETRY_BACKOFF_BASE == 2
assert 429 in RETRY_STATUS_CODES
assert 500 in RETRY_STATUS_CODES
assert 502 in RETRY_STATUS_CODES
assert 503 in RETRY_STATUS_CODES
assert 504 in RETRY_STATUS_CODES
def test_env_var_override(self, monkeypatch: object) -> None:
"""Test that env vars override defaults at import time."""
# We can't easily re-import the module, but we can verify
# the constants respect env vars by checking the module source.
import devx.config as cfg
assert cfg.GITEA_API_URL # always non-empty
assert cfg.VIKUNJA_API_URL # always non-empty
class TestTaskPrefixOverride:
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "INFRA"
assert cfg.TASK_ID_RE.search("INFRA-42")
assert not cfg.TASK_ID_RE.search("DEVX-42")
# Restore
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
importlib.reload(cfg)
+165
View File
@@ -0,0 +1,165 @@
"""Unit tests for devx.tools.configure_repo."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.exceptions import APIError
from devx.tools.configure_repo import (
_default_branch_protection_config,
_default_repo_settings_config,
_handle_http_error,
configure_repo,
main,
)
class TestHandleHttpError:
def test_forbidden_raises_click_exception(self) -> None:
with pytest.raises(click.ClickException, match="Forbidden"):
_handle_http_error(APIError(403, "Forbidden"))
def test_other_error_raises_click_exception(self) -> None:
with pytest.raises(click.ClickException, match="HTTP error"):
_handle_http_error(APIError(500, "Server error"))
class TestDefaultConfigs:
def test_default_branch_protection_config(self) -> None:
config = _default_branch_protection_config()
assert config["branch_name"] == "master"
assert config["enable_push"] is True
assert config["enable_push_whitelist"] is False
assert config["required_approvals"] == 0
assert isinstance(config["status_check_contexts"], list)
assert "CI / quality (pull_request)" in config["status_check_contexts"]
def test_default_repo_settings_config(self) -> None:
config = _default_repo_settings_config()
assert config["default_delete_branch_after_merge"] is True
def test_status_checks_from_env(self) -> None:
with patch.dict("os.environ", {"DEVX_STATUS_CHECKS": "check1, check2, check3"}):
config = _default_branch_protection_config()
assert config["status_check_contexts"] == ["check1", "check2", "check3"]
class TestConfigureRepo:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
configure_repo(token="tok", owner="owner", repo="repo")
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Forbidden"):
configure_repo(token="tok", owner="owner", repo="repo")
def test_configure_repo_no_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
configure_repo(token="", owner="owner", repo="repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
custom_bp = {
"branch_name": "develop",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": [],
"enable_status_check": True,
"status_check_contexts": ["CI / quality (pull_request)"],
"required_approvals": 2,
}
custom_rs = {"default_delete_branch_after_merge": False}
configure_repo(
token="tok",
owner="owner",
repo="repo",
branch="develop",
branch_protection_config=custom_bp,
repo_settings_config=custom_rs,
)
mock_client.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0
assert "Forbidden" in result.output
@patch.dict("os.environ", {}, clear=True)
def test_main_no_token(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_main_no_repo(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Repository name not specified" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo", "--branch", "develop"])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop"
+76
View File
@@ -0,0 +1,76 @@
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 devx.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()
+239
View File
@@ -0,0 +1,239 @@
"""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 devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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
@patch("devx.ci.discover_runners.requests.get")
def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""403 on instance-level runners should not produce a warning (expected without admin scope)."""
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
captured = capsys.readouterr()
assert "instance-level" not in captured.err
@patch("devx.ci.discover_runners.requests.get")
def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""Non-200, non-403 status on instance-level runners should produce a warning."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=500, json=lambda: {"message": "server error"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 2
captured = capsys.readouterr()
assert "instance-level" in captured.err
assert "500" in captured.err
class TestGetRunnerCount:
@patch("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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
+154
View File
@@ -0,0 +1,154 @@
"""Unit tests for devx.ci.distribute_files."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from devx.ci.distribute_files import (
DEFAULT_MAX_RUNNERS,
discover_files,
distribute,
files_for_runner,
main,
)
class TestDiscoverFiles:
def test_discovers_sorted(self, tmp_path: Path) -> None:
(tmp_path / "test_b.py").write_text("")
(tmp_path / "test_a.py").write_text("")
result = discover_files(str(tmp_path / "test_*.py"))
assert len(result) == 2
assert result[0].endswith("test_a.py")
assert result[1].endswith("test_b.py")
def test_no_matches(self, tmp_path: Path) -> None:
assert discover_files(str(tmp_path / "nonexistent-*.py")) == []
class TestDistribute:
def test_even_split(self) -> None:
files = [f"test_{i}.py" for i in range(6)]
groups = distribute(files, 3)
assert len(groups) == 3
assert all(len(g) == 2 for g in groups)
def test_uneven_split(self) -> None:
files = [f"test_{i}.py" for i in range(5)]
groups = distribute(files, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_files(self) -> None:
files = ["test_a.py"]
groups = distribute(files, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty(self) -> None:
assert distribute([], 3) == [[], [], []]
class TestFilesForRunner:
def test_returns_correct_subset(self) -> None:
files = [f"test_{i}.py" for i in range(6)]
assert len(files_for_runner(files, 0, 3)) == 2
assert len(files_for_runner(files, 1, 3)) == 2
assert len(files_for_runner(files, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
with pytest.raises(Exception, match="out of range"):
files_for_runner(["a.py"], 5, 3)
class TestCli:
def test_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
for i in range(3):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--pattern", str(tmp_path / "test_*.py"), "--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:
for i in range(3):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3"],
)
assert result.exit_code == 0
assert "test_0.py" in result.output
def test_github_env_writes_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
for i in range(2):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "ASSIGNED_FILES=" 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))
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
[
"--pattern",
str(tmp_path / "test_*.py"),
"--runner-index",
"5",
"--max-runners",
"2",
"--github-env",
"--skip-if-excess",
],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "ASSIGNED_FILES=\n" in content
assert "SKIP=true" in content
def test_runner_index_zero_raises(self, tmp_path: Path) -> None:
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "0", "--max-runners", "3"],
)
assert result.exit_code != 0
def test_no_env_var_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3", "--github-env"],
)
assert result.exit_code != 0
def test_default_max_runners() -> None:
assert DEFAULT_MAX_RUNNERS == 3
def test_main_module_block() -> None:
import devx.ci.distribute_files as mod
assert hasattr(mod, "main")
+479
View File
@@ -0,0 +1,479 @@
"""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 devx.molecule.distribute_molecule import (
DEFAULT_ROLES_ROOT,
MOLECULE_ROOT,
PLATFORMS,
MultiRoleTestPair,
TestPair,
build_multi_role_pairs,
build_pairs,
cli,
discover_multi_role_scenarios,
discover_scenarios,
distribute,
distribute_multi_role,
multi_role_pairs_for_runner,
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 devx.molecule.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
(root / "beta").mkdir(parents=True)
with patch("devx.molecule.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 devx.molecule.distribute_molecule import cli
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2604" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
root = tmp_path / "molecule"
for s in ["a", "b", "c"]:
(root / s).mkdir(parents=True)
with patch("devx.molecule.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 devx.molecule.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
with patch("devx.molecule.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-2604" 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("devx.molecule.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("devx.molecule.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("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code != 0
class TestRunnerIndexValidation:
def test_runner_index_zero_raises(self) -> None:
"""Runner index < 1 should raise."""
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
def test_main_module_block() -> None:
import devx.molecule.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"])
class TestDiscoverMultiRole:
def test_discovers_role_scenario_pairs(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
for scenario in ["default", "binary"]:
(roles / "gitea-runner" / "molecule" / scenario).mkdir(parents=True)
(roles / "gitea-runner" / "molecule" / "common").mkdir(parents=True)
(roles / "gitea-runner" / "molecule" / "_shared").mkdir(parents=True)
(roles / "docker-base" / "molecule" / "default").mkdir(parents=True)
(roles / "no-molecule").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("docker-base", "default") in result
assert ("gitea-runner", "default") in result
assert ("gitea-runner", "binary") in result
assert ("gitea-runner", "common") not in result
assert ("gitea-runner", "_shared") not in result
assert len(result) == 3
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
with pytest.raises(click.ClickException) as exc:
discover_multi_role_scenarios(tmp_path / "nonexistent")
assert "not found" in str(exc.value)
def test_default_roles_root_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Calling with no args uses DEFAULT_ROLES_ROOT which doesn't exist in tests."""
with pytest.raises(click.ClickException):
discover_multi_role_scenarios()
def test_default_roles_root_constant(self) -> None:
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
class TestMultiRoleTestPair:
def test_encode_roundtrip(self) -> None:
pair = MultiRoleTestPair(
"docker-base", "default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""}
)
encoded = pair.encode()
assert encoded == "docker-base|default|ubuntu-2204|ubuntu:22.04|"
decoded = MultiRoleTestPair.decode(encoded)
assert decoded.role == "docker-base"
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
class TestBuildMultiRolePairs:
def test_cross_product(self) -> None:
role_scenarios = [("role-a", "default"), ("role-b", "binary")]
platforms = [{"name": "p1", "image": "i1", "command": ""}]
pairs = build_multi_role_pairs(role_scenarios, platforms)
assert len(pairs) == 2
assert pairs[0].role == "role-a"
assert pairs[1].role == "role-b"
def test_default_platforms(self) -> None:
pairs = build_multi_role_pairs([("r", "s")])
assert len(pairs) == len(PLATFORMS)
class TestDistributeMultiRole:
def test_even_split(self) -> None:
pairs = [MultiRoleTestPair(f"r{i}", "s", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute_multi_role(pairs, 3)
assert all(len(g) == 2 for g in groups)
def test_out_of_range_raises(self) -> None:
pairs = [MultiRoleTestPair("r", "s", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException):
multi_role_pairs_for_runner(pairs, 5, 3)
class TestCliMultiRole:
def test_roles_root_list(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list"])
assert result.exit_code == 0
assert "role-a|default" in result.output
assert "role-b|binary" in result.output
def test_roles_root_runner_index(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
assert "role-a|default|" in result.output
def test_roles_root_github_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(
cli,
["--roles-root", str(roles), "--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_roles_root_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles),
"--runner-index",
"5",
"--max-runners",
"2",
"--github-env",
"--skip-if-excess",
],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "SKIP=true" in content
def test_molecule_root_option(self, tmp_path: Path) -> None:
root = tmp_path / "custom-molecule"
(root / "alpha").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--molecule-root", str(root), "--list"])
assert result.exit_code == 0
assert "alpha" in result.output
def test_roles_root_list_platforms(self, tmp_path: Path) -> None:
"""--roles-root --list-platforms prints platforms."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2604" in result.output
def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
"""--roles-root without --runner-index prints all groups."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--max-runners", "2"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
def test_platforms_file_overrides_default(self, tmp_path: Path) -> None:
"""--platforms-file loads custom platforms from JSON."""
import json
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
platforms_file = tmp_path / "platforms.json"
custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}]
platforms_file.write_text(json.dumps(custom))
runner = CliRunner()
result = runner.invoke(
cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"]
)
assert result.exit_code == 0
assert "custom-os" in result.output
assert "custom:latest" in result.output
def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None:
"""Non-directory entries in roles root are skipped."""
roles = tmp_path / "roles"
roles.mkdir(parents=True)
(roles / "README.md").write_text("not a role")
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_skips_non_dir_scenario(self, tmp_path: Path) -> None:
"""Non-directory entries in molecule dir are skipped."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule").mkdir(parents=True)
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-a" / "molecule" / "file.txt").write_text("not a scenario")
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_skips_role_without_molecule(self, tmp_path: Path) -> None:
"""Roles without a molecule/ directory are skipped."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "no-molecule").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_runner_index_zero_raises(self, tmp_path: Path) -> None:
"""--roles-root --runner-index 0 should raise."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
+123
View File
@@ -0,0 +1,123 @@
"""Unit tests for devx.ci.doc_coverage."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from devx.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()
# devx CLI has commands under ci, tools, and molecule groups
assert "auto-merge" in commands
assert "release" in commands
assert "publish" in commands
assert "setup" in commands
assert "install-tools" in commands
def test_returns_list(self) -> None:
commands = extract_cli_commands()
assert isinstance(commands, list)
assert len(commands) > 0
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Returns empty list when CLI file doesn't exist."""
from devx.ci import doc_coverage
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
commands = extract_cli_commands()
assert commands == []
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When a command decorator has no explicit name, falls back to the def name."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
assert "my_command" in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
content = "## auto-merge\n\nAuto-merge PR."
assert check_command_documented("auto-merge", content) is True
def test_finds_command_in_code_block(self) -> None:
content = "```bash\ndevx ci release --dry-run\n```"
assert check_command_documented("release", content) is True
def test_finds_command_with_devx_prefix(self) -> None:
content = "Use `devx tools setup` to install."
assert check_command_documented("setup", content) is True
def test_missing_command(self) -> None:
content = "## Other stuff\n\nNo commands here."
assert check_command_documented("release", 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)
# Get actual commands from the CLI
commands = extract_cli_commands()
# Write cli-commands.md with all commands
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
(docs / "user" / "cli-commands.md").write_text(cli_content)
# Write architecture.md with all modules
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
# Write ci-cd-workflow.md with all scripts
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
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
+351
View File
@@ -0,0 +1,351 @@
"""Unit tests for scripts/generate_badges.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.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("devx.tools.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/devx/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:
@patch("devx.tools.generate_badges._find_package_init")
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
mock_init = MagicMock()
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
mock_find.return_value = mock_init
assert read_version() == "0.5.0"
@patch("devx.tools.generate_badges._find_package_init")
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
mock_init = MagicMock()
mock_init.read_text.return_value = "no version here\n"
mock_find.return_value = mock_init
assert read_version() == "unknown"
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
assert read_version() == "unknown"
class TestFindPackageInit:
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_no_src_dir(self, mock_root: MagicMock) -> None:
"""Returns None when src/ directory doesn't exist."""
from devx.tools.generate_badges import _find_package_init
mock_src = MagicMock()
mock_src.exists.return_value = False
mock_root.__truediv__ = MagicMock(return_value=mock_src)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns None when no __init__.py has __version__."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "__init__.py").write_text("# no version here\n")
mock_root.__truediv__ = MagicMock(return_value=src_dir)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns the __init__.py that has __version__."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
pkg_dir = src_dir / "mypkg"
pkg_dir.mkdir(parents=True)
(src_dir / "__init__.py").write_text("# no version\n")
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
mock_root.__truediv__ = MagicMock(return_value=src_dir)
result = _find_package_init()
assert result is not None
assert "__version__" in result.read_text()
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Handles OSError when reading init files."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
src_dir.mkdir()
init_file = src_dir / "__init__.py"
init_file.write_text('__version__ = "1.0.0"\n')
mock_root.__truediv__ = MagicMock(return_value=src_dir)
# Patch Path.read_text to raise OSError
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
result = _find_package_init()
assert result is None
class TestGenerateBadges:
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.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("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.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("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.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("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
@patch("devx.tools.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("devx.tools.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 devx.tools.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([])
+124
View File
@@ -0,0 +1,124 @@
"""Tests for devx.tools.generate_cliff_config."""
from __future__ import annotations
import tomllib
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.tools.generate_cliff_config import main
class TestGenerateCliffConfig:
"""Tests for the generate_cliff_config tool."""
@pytest.fixture
def runner(self) -> CliRunner:
return CliRunner()
def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate cliff.toml to a new file."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert output.exists()
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content
def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX')."""
output = tmp_path / "cliff.toml"
with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"):
result = runner.invoke(main, ["--output", str(output)])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for DEVX" in content
def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Refuse to overwrite existing file without --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code != 0
assert "already exists" in result.output
assert output.read_text() == "# existing"
def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Overwrite existing file with --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert "# existing" not in content
def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must be valid TOML."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
assert "changelog" in data
assert "git" in data
assert "bump" in data
assert data["bump"]["initial_tag"] == "0.1.0"
assert data["bump"]["features_always_bump_minor"] is True
def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None:
"""Preprocessor pattern must match the given prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
preprocessors = data["git"]["commit_preprocessors"]
assert len(preprocessors) == 1
pattern = preprocessors[0]["pattern"]
assert "INFRA" in pattern
def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must have all standard commit parsers."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
parsers = data["git"]["commit_parsers"]
# Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all
messages = [p["message"] for p in parsers if "message" in p]
assert "^feat" in messages
assert "^fix" in messages
assert "^perf" in messages
assert "^refactor" in messages
assert "^release:" in messages
assert "^revert" in messages
assert ".*" in messages # catch-all
def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None:
"""Default output path is cliff.toml in current directory."""
output = tmp_path / "cliff.toml"
# Change to tmp_path so default cliff.toml is created there
import os
old_cwd = os.getcwd()
os.chdir(tmp_path)
try:
result = runner.invoke(main, ["--prefix", "GRM"])
assert result.exit_code == 0
assert output.exists()
finally:
os.chdir(old_cwd)
def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
"""Success message includes file and prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert "Generated" in result.output
assert "GRM" in result.output
+355
View File
@@ -0,0 +1,355 @@
"""Unit tests for scripts/gitea_cli.py."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from devx.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
@@ -0,0 +1,94 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
import devx.tools.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
@@ -0,0 +1,312 @@
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 devx.tools.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
+311
View File
@@ -0,0 +1,311 @@
"""Unit tests for devx.ci.integration_guard."""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.integration_guard import cli
class TestCli:
def test_all_pass(self) -> None:
with (
patch("devx.ci.integration_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, ["--", "tests/integration/test_foo.py"])
assert result.exit_code == 0
assert "Integration tests passed" in result.output
def test_failure_exits_nonzero(self) -> None:
with (
patch("devx.ci.integration_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, ["--", "tests/integration/test_foo.py"])
assert result.exit_code == 1
assert "failed" in result.output
def test_junit_output_passed_to_pytest(self) -> None:
with (
patch("devx.ci.integration_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,
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "--junitxml" in call_args
assert "junit-results/runner-1.xml" in call_args
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_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,
["--", "-x", "-v", "--tb=short", "test_a.py", "test_b.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "-x" in call_args
assert "-v" in call_args
assert "test_a.py" in call_args
assert "test_b.py" in call_args
def test_keyboard_interrupt_kills_process(self) -> None:
with (
patch("devx.ci.integration_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, ["--", "test_foo.py"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
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": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.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, ["--", "test_foo.py"])
assert result.exit_code == 1
mock_killpg.assert_called()
assert "cancelled" in result.output.lower()
def test_process_lookup_error_suppressed(self) -> None:
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": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
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, ["--", "test_foo.py"])
assert result.exit_code == 1
def test_timeout_expired_kills_with_sigkill(self) -> None:
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": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.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.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
# SIGKILL should have been called (second killpg call)
assert mock_killpg.call_count >= 2
def test_no_env_vars_runs_without_polling(self) -> None:
with (
patch.dict(os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True),
patch("devx.ci.integration_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, ["--", "test_foo.py"])
assert result.exit_code == 0
assert "without cross-runner cancellation" in result.output
def test_partial_env_vars_runs_without_polling(self) -> None:
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling."""
with (
patch.dict(
os.environ,
{"GITEA_URL": "https://gitea.example", "PATH": os.environ.get("PATH", "")},
clear=True,
),
patch("devx.ci.integration_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, ["--", "test_foo.py"])
assert result.exit_code == 0
assert "without cross-runner cancellation" in result.output
def test_invalid_repository_falls_back_to_default(self) -> None:
"""GITEA_REPOSITORY without '/' falls back to oblachno-oss/devx."""
with (
patch.dict(
os.environ,
{"GITEA_REPOSITORY": "invalid", "PATH": os.environ.get("PATH", "")},
clear=True,
),
patch("devx.ci.integration_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, ["--", "test_foo.py"])
assert result.exit_code == 0
def test_main_module_block() -> None:
import devx.ci.integration_guard as ig
with open(ig.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(ig.__dict__)
exec(compile(source, ig.__file__, "exec"), namespace)
assert callable(namespace["cli"])

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