Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f40223d2 | ||
|
|
06e80516d4 | ||
|
|
f1adf22c3e | ||
|
|
91216da1a4 | ||
|
|
f9836208df | ||
|
|
0f0f0b683a | ||
|
|
54f687f1bf | ||
|
|
44c906a5e6 | ||
|
|
a3d528f802 | ||
|
|
e3a7afc0b0 | ||
|
|
700d3b55c6 | ||
|
|
701363d935 | ||
|
|
ddb2d43b4e | ||
|
|
fe6373b682 | ||
|
|
33434d5750 | ||
|
|
dfcd33c35b | ||
|
|
0aefe1f028 | ||
|
|
891b0b5dba | ||
|
|
8f15e5402b | ||
|
|
9060cd7b1e | ||
|
|
f687ab5aa3 | ||
|
|
4738b594b2 | ||
|
|
f6e9f2013b | ||
|
|
8450f33e88 | ||
|
|
904812dfae | ||
|
|
95384c26e1 | ||
|
|
c10b759f6b | ||
|
|
4c818b32ce | ||
|
|
bbf09c07df | ||
|
|
faff67aa6a | ||
|
|
3e4dfcadb7 | ||
|
|
2385747bed | ||
|
|
3625bf2872 | ||
|
|
8affccfa35 | ||
|
|
7ae85b6955 | ||
|
|
f702286779 | ||
|
|
d4b58fa86f | ||
|
|
44c6c42ede | ||
|
|
14c585971d | ||
|
|
3e2342c347 | ||
|
|
f3d5b0ff45 | ||
|
|
75e36897cc | ||
|
|
0eef69a902 | ||
|
|
f9130884d1 | ||
|
|
ba002c2e72 | ||
|
|
af610d22ec | ||
|
|
69a585db2f | ||
|
|
700df828ba | ||
|
|
4df0602157 | ||
|
|
d7d90fe165 | ||
|
|
22c2d7c925 | ||
|
|
b4350751f1 | ||
|
|
a987b63da7 | ||
|
|
82d613e23b | ||
|
|
6b6c9d40f1 | ||
|
|
d17854296a | ||
|
|
2fff7ed271 | ||
|
|
0a4d66ab5c | ||
|
|
14c9200179 | ||
|
|
85ae272b1f | ||
|
|
a0c4c1c7f0 | ||
|
|
fd0c4de31e | ||
|
|
8f7af97335 | ||
|
|
a14d838564 | ||
|
|
7dcb9c03c0 | ||
|
|
6f2b110c17 | ||
|
|
cfb856ff75 | ||
|
|
95adf86895 | ||
|
|
7cf039ebbe | ||
|
|
a17982f2cf | ||
|
|
cf85964877 | ||
|
|
fbb1fc3134 | ||
|
|
a8f86aca68 | ||
|
|
13bed1d99c | ||
|
|
107cff5dec | ||
|
|
cb037aa69c | ||
|
|
7fa1c4450c | ||
|
|
cf2921845b | ||
|
|
c272150275 | ||
|
|
8de91be405 | ||
|
|
46b8fe5078 | ||
|
|
7e2a8b4535 | ||
|
|
c90518acdb | ||
|
|
80ca622838 | ||
|
|
3f2d19d7ac | ||
|
|
c839d49fe3 | ||
|
|
93b5d2f926 | ||
|
|
131c04c9d0 | ||
|
|
8e9681cf7d | ||
|
|
6631525a1d | ||
|
|
6985030a3c | ||
|
|
4d073f3beb | ||
|
|
037d7b0d16 | ||
|
|
9cb706e387 | ||
|
|
5bd6158f2a | ||
|
|
05aa2ffe76 | ||
|
|
b9c3b55680 | ||
|
|
d398c8e971 | ||
|
|
39526d8e6a | ||
|
|
37730e2187 | ||
|
|
e2f66ca70a | ||
|
|
0b88c211f1 | ||
|
|
ea4ee0d303 | ||
|
|
16fba17b03 | ||
|
|
011cf3e093 | ||
|
|
daf99c5fed | ||
|
|
7154e3ad7c | ||
|
|
6053fb9fba | ||
|
|
e76741bfad | ||
|
|
f206a9cd8d | ||
|
|
b4b7428f9c | ||
|
|
0d9e76a838 | ||
|
|
034cbde2f7 | ||
|
|
e0abe6f176 | ||
|
|
15f6837dc2 | ||
|
|
b4dda91e24 | ||
|
|
3e21e774f7 | ||
|
|
7c11215e57 | ||
|
|
5384269c83 | ||
|
|
b3d0dd8ca7 | ||
|
|
a7dcaee5c6 | ||
|
|
02f8d3757b | ||
|
|
4311fb7648 | ||
|
|
2ead959fcf | ||
|
|
9a60009d29 | ||
|
|
c20dfd185a | ||
|
|
547fef4f27 | ||
|
|
23183df7c7 | ||
|
|
f382408115 | ||
|
|
19bec24f45 | ||
|
|
37772f21a9 | ||
|
|
b07132e3c6 | ||
|
|
7b3b604c2c | ||
|
|
53990dc10c | ||
|
|
8ea044a942 | ||
|
|
8411c92c95 | ||
|
|
621b9936c8 | ||
|
|
33063038a1 | ||
|
|
0c3e8a7b8d | ||
|
|
6436c5dd38 | ||
|
|
33b09c162d | ||
|
|
a2c856d8b2 | ||
|
|
7d9a081c92 | ||
|
|
87d730d8be | ||
|
|
89a165be46 | ||
|
|
388c3df043 | ||
|
|
ac8a1d3be4 | ||
|
|
07cca5de36 | ||
|
|
0800119795 | ||
|
|
0c378ed8e2 | ||
|
|
6297b50601 | ||
|
|
a7372a9169 | ||
|
|
1a80967967 | ||
|
|
6ebb48f9e8 | ||
|
|
31bfd23fea | ||
|
|
60fd11419c |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="20" role="img"
|
||||
aria-label="tests: 2189 passing">
|
||||
<title>tests: 2189 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">2189 passing</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 909 B |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="118" height="20" role="img"
|
||||
aria-label="version: v0.49.5">
|
||||
<title>version: v0.49.5</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.49.5</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 903 B |
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# actionlint configuration for Gitea Actions workflows
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- docker
|
||||
@@ -0,0 +1,152 @@
|
||||
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 the branch name, validates the PR title, and squash-merges.
|
||||
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"
|
||||
@@ -0,0 +1,262 @@
|
||||
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: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
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: Publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "No tag found — skipping publish"
|
||||
exit 0
|
||||
fi
|
||||
echo "Publishing release $TAG (idempotent — skips if already published)..."
|
||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
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 }}"
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. v0.9.11)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
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.event.inputs.tag || 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 }}"
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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/
|
||||
|
||||
# Deprecated CI task tracking (branch name is the sole source of truth)
|
||||
.taskid
|
||||
@@ -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
|
||||
@@ -0,0 +1,345 @@
|
||||
# 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)
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── 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 (--roles-root)
|
||||
├── 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>`
|
||||
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` |
|
||||
|
||||
### Task ID Resolution
|
||||
|
||||
`auto_merge` resolves the task ID solely from the branch name (e.g.
|
||||
`DEVX-12-fix-foo` → `DEVX-12`). Branch names must include the task ID
|
||||
prefix — there is no `.taskid` file fallback. If a stale `.taskid` file
|
||||
exists in the repo, a deprecation warning is printed advising its removal.
|
||||
|
||||
### Workflow `auto-merge` Job and `always()`
|
||||
|
||||
When `auto-merge` depends on a job that can be skipped (e.g.
|
||||
`molecule-tests`), the `if:` condition MUST include `always() &&`
|
||||
at the start. Without it, Gitea Actions skips `auto-merge` when any
|
||||
dependency is skipped, even if the condition explicitly allows
|
||||
`result == 'skipped'`.
|
||||
|
||||
```yaml
|
||||
auto-merge:
|
||||
needs: [quality, detect-changes, pr-review, molecule-tests]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
```
|
||||
|
||||
## 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_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `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
|
||||
@@ -0,0 +1,322 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.16.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- Single-source-of-truth config via [tool.devx] in pyproject.toml
|
||||
|
||||
## [0.15.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- Add create-task, create-pr, pre-push-check tools and devx.mak fragment
|
||||
|
||||
## [0.14.2] - 2026-06-26
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
|
||||
|
||||
## [0.14.1] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Handle 'already a release' error idempotently in publish
|
||||
|
||||
## [0.14.0] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
|
||||
|
||||
## [0.13.0] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- Add --force flag to classify_changes, fix api_clients coverage
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Squash-merge format uses space not colon after task ID
|
||||
- Revert squash-merge format to use colon after task ID
|
||||
|
||||
## [0.1.0] - 2026-06-25
|
||||
|
||||
## [0.12.5] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make PyPI publish failures non-fatal
|
||||
|
||||
## [0.12.4] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Pass REPO_TOKEN to setup-release so tea login is configured
|
||||
- Guarantee Gitea release for every tag
|
||||
|
||||
## [0.12.3] - 2026-06-25
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove JUnit reporting from devx
|
||||
|
||||
## [0.12.2] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove auto-rebase from auto-merge to prevent CI feedback loop
|
||||
|
||||
## [0.12.1] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use heredoc syntax for multi-line $GITHUB_ENV values
|
||||
|
||||
## [0.12.0] - 2026-06-24
|
||||
|
||||
### Features
|
||||
|
||||
- Add Polish as officially supported language
|
||||
|
||||
## [0.11.1] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add build/twine to ci deps, activate venv in notify_failure
|
||||
|
||||
## [0.11.0] - 2026-06-24
|
||||
|
||||
### Features
|
||||
|
||||
- Add publish step to post-merge release job, make publish idempotent
|
||||
|
||||
## [0.10.2] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Badge generation respects pyproject.toml testpaths, shows stdout in warnings
|
||||
|
||||
## [0.10.1] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Badge generation REPO_ROOT, auto-detect package, error feedback
|
||||
|
||||
## [0.10.0] - 2026-06-24
|
||||
|
||||
### Features
|
||||
|
||||
- Remove .taskid file fallback, use branch name only
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use raw/branch/badges/ URLs for badges in README and docs
|
||||
|
||||
## [0.9.12] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Clean dist/ before build and add workflow_dispatch to publish
|
||||
|
||||
## [0.9.11] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use raw/branch/badges/ URLs for badges in README and docs
|
||||
- Resolve repo_root from GITHUB_WORKSPACE or cwd
|
||||
|
||||
## [0.9.10] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retrospective fixes for CI/CD friction
|
||||
|
||||
## [0.9.9] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
|
||||
- Prefer branch name for task ID extraction + strip heads/ prefix in release
|
||||
- Filter non-version tags in release verification
|
||||
- Use explicit refspecs for git push to avoid tag/branch ambiguity
|
||||
|
||||
## [0.9.8] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
|
||||
|
||||
## [0.9.7] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add rootless socket fallback and GITHUB_ENV export
|
||||
|
||||
## [0.9.6] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add Docker socket diagnostics to start_docker
|
||||
- Add Docker socket diagnostics to start_docker
|
||||
|
||||
## [0.9.5] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
|
||||
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
|
||||
|
||||
## [0.9.4] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use separate Docker socket for DinD in CI
|
||||
|
||||
## [0.9.3] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use tempfile for dockerd log to fix CI permission error
|
||||
|
||||
## [0.9.2] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use vfs storage driver for Docker-in-Docker in CI
|
||||
|
||||
## [0.9.1] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Always start dockerd in CI runner for molecule tests
|
||||
|
||||
## [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
|
||||
@@ -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>.
|
||||
@@ -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/
|
||||
@@ -0,0 +1,459 @@
|
||||
# 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, documentation coverage,
|
||||
parallel test distribution, 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 repositories. Any project hosted
|
||||
on a Gitea instance with Gitea Actions can install devx and inherit a complete,
|
||||
opinionated CI/CD pipeline: conventional commits, automated versioning via
|
||||
git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and
|
||||
quality badges.
|
||||
|
||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
Every oblachno-oss project shares the same CI/CD needs: automated releases,
|
||||
PR review, task tracking, documentation sync, and quality badges. Without a
|
||||
shared package, each repository duplicates this logic in shell scripts and
|
||||
workflow YAML, leading to drift, bugs, and maintenance burden.
|
||||
|
||||
devx solves this by providing a single, tested Python package that any
|
||||
oblachno-oss project can install. The project declares its configuration via
|
||||
environment variables and `pyproject.toml`, and devx handles the rest. Updates
|
||||
to the CI/CD pipeline ship as new devx releases — consumer projects pick them
|
||||
up by bumping their devx dependency.
|
||||
|
||||
### Key features
|
||||
|
||||
- **Automated releases** — git-cliff-driven semver versioning, changelog
|
||||
generation, tagging, and publishing to a Gitea PyPI registry.
|
||||
- **PR automation** — squash-merge with task ID validation, automated PR
|
||||
review with inline comments, and conventional commit enforcement.
|
||||
- **Smart change classification** — user-facing vs workflow-only change
|
||||
detection so infrastructure-only changes skip releases.
|
||||
- **Documentation sync** — push `docs/` markdown to the Gitea wiki with
|
||||
integrity verification.
|
||||
- **Quality badges** — generate self-contained SVG badges for coverage,
|
||||
tests, docs, quality, version, and Python version.
|
||||
- **Translation checks** — validate i18n keys against source code, detect
|
||||
dead keys and missing languages.
|
||||
- **Parallel test distribution** — split test files or molecule scenarios
|
||||
across CI runners with cross-runner fail-fast.
|
||||
- **Developer tools** — environment setup, CI tool installation, test speed
|
||||
enforcement, repository configuration.
|
||||
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
|
||||
Chinese, and Polish; projects can extend with their own keys.
|
||||
|
||||
## Installation
|
||||
|
||||
devx is published to the Gitea PyPI registry at
|
||||
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
|
||||
The registry is publicly readable — no authentication required to install.
|
||||
|
||||
### Quick install (one-off)
|
||||
|
||||
```bash
|
||||
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
### Persistent configuration (recommended)
|
||||
|
||||
Add the registry to `~/.pip/pip.conf` so `pip install devx` works without
|
||||
specifying `--index-url` every time:
|
||||
|
||||
```ini
|
||||
[global]
|
||||
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
### As a dependency in another project
|
||||
|
||||
To use devx as a dependency in your `pyproject.toml`, add the registry as an
|
||||
extra index and list devx in your dependencies:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.11.1",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Then install normally:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
|
||||
> (e.g., `"devx>=0.11.1,<0.12"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
devx ships optional dependency groups for different use cases:
|
||||
|
||||
```bash
|
||||
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
|
||||
pip install "devx[molecule]" # Molecule testing for Ansible projects
|
||||
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
After installing devx, set the required environment variables (see
|
||||
[Configuration](#configuration)) and invoke modules via `python -m devx.*` or
|
||||
the `devx` CLI.
|
||||
|
||||
### CI/CD automation
|
||||
|
||||
CI/CD modules are invoked via `python -m devx.ci.*`. Each module is also
|
||||
available as a `devx ci <command>` subcommand.
|
||||
|
||||
```bash
|
||||
# Release automation (versioning, changelog, tagging)
|
||||
python -m devx.ci.release
|
||||
python -m devx.ci.release --dry-run # preview without changes
|
||||
python -m devx.ci.release --verify # check tag/version/changelog alignment
|
||||
|
||||
# Publish a release to the Gitea PyPI registry
|
||||
python -m devx.ci.publish v1.0.0 oblachno-oss/devx
|
||||
python -m devx.ci.publish v1.0.0 oblachno-oss/devx --skip-build # Gitea release only
|
||||
|
||||
# Automated PR review (posts inline comments and structured 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
|
||||
python -m devx.ci.push_badges --retries 3 # retry on git push failures
|
||||
|
||||
# Check translation completeness
|
||||
python -m devx.ci.check_translations
|
||||
python -m devx.ci.check_translations --translations path/to/translations.json
|
||||
|
||||
# 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
|
||||
|
||||
# Detect whether the latest commit is a release commit
|
||||
python -m devx.ci.detect_release_commit
|
||||
|
||||
# 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 --auto-login
|
||||
|
||||
# Discover available Gitea Actions runners
|
||||
python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
|
||||
|
||||
# Distribute files across parallel runners (round-robin)
|
||||
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
|
||||
--runner-index 1 --max-runners 3 --github-env
|
||||
|
||||
# Run pytest with cross-runner fail-fast
|
||||
python -m devx.ci.integration_guard -- test_a.py test_b.py
|
||||
```
|
||||
|
||||
### Developer tools
|
||||
|
||||
Developer tooling modules are invoked via `python -m devx.tools.*` or the
|
||||
`devx tools <command>` subcommand.
|
||||
|
||||
```bash
|
||||
# Set up a development environment (venv, deps, hooks, tea login)
|
||||
python -m devx.tools.setup --bin .venv/bin
|
||||
python -m devx.tools.setup --bin .venv/bin --extras "ci,lint" --no-pre-commit
|
||||
|
||||
# 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
|
||||
python -m devx.tools.install_tools --list
|
||||
|
||||
# Install checkmake (Makefile linter)
|
||||
python -m devx.tools.install_checkmake
|
||||
|
||||
# Check unit test speed
|
||||
python -m devx.tools.check_test_speed --max-seconds 10
|
||||
python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
|
||||
# Configure repository (branch protection, labels)
|
||||
python -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
|
||||
# Generate badge SVG files locally
|
||||
python -m devx.tools.generate_badges --output-dir .badges/
|
||||
|
||||
# Generate a cliff.toml with the correct task ID prefix
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
|
||||
```
|
||||
|
||||
### Molecule testing (optional)
|
||||
|
||||
For projects with Ansible roles, devx provides molecule testing helpers via
|
||||
`python -m devx.molecule.*` or `devx molecule <command>`.
|
||||
|
||||
```bash
|
||||
# Distribute molecule scenarios across parallel runners
|
||||
python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
|
||||
python -m devx.molecule.distribute_molecule --list # list all scenarios
|
||||
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
||||
|
||||
# Run molecule tests with cross-runner fail-fast
|
||||
python -m devx.molecule.molecule_ci_guard pair1 pair2
|
||||
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
|
||||
|
||||
# Run all molecule scenarios locally (sequential)
|
||||
python -m devx.molecule.molecule_all
|
||||
python -m devx.molecule.molecule_all --bin .venv/bin
|
||||
|
||||
# Discover available Gitea Actions runners for molecule tests
|
||||
python -m devx.molecule.discover_runners --indices
|
||||
|
||||
# Ensure Docker is available for molecule tests in CI
|
||||
python -m devx.molecule.start_docker
|
||||
```
|
||||
|
||||
### OpenTofu helpers
|
||||
|
||||
devx provides reusable functions for extracting values from `tofu output`:
|
||||
|
||||
```python
|
||||
from devx.opentofu import get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field
|
||||
|
||||
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})
|
||||
```
|
||||
|
||||
## CLI commands overview
|
||||
|
||||
devx provides a `devx` CLI command with three command groups:
|
||||
|
||||
```bash
|
||||
devx --help
|
||||
devx --version
|
||||
```
|
||||
|
||||
### `devx ci` — CI/CD automation
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `devx ci auto-merge` | Squash-merge a PR with task ID validation |
|
||||
| `devx ci check-translations` | Check translation files for gaps and dead keys |
|
||||
| `devx ci classify-changes` | Classify git changes as user-facing or workflow-only |
|
||||
| `devx ci detect-release-commit` | Detect whether the latest commit is a release commit |
|
||||
| `devx ci discover-runners` | Discover available Gitea Actions runners |
|
||||
| `devx ci distribute-files` | Distribute files across parallel runners (round-robin) |
|
||||
| `devx ci doc-coverage` | Check documentation coverage for CLI commands and modules |
|
||||
| `devx ci integration-guard` | Run pytest with cross-runner fail-fast |
|
||||
| `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 |
|
||||
| `devx ci publish` | Build package, publish to registry, create Gitea release |
|
||||
| `devx ci push-badges` | Generate badge SVG files and push to the badges branch |
|
||||
| `devx ci release` | Automated release: version, changelog, tag, push |
|
||||
| `devx ci sync-wiki` | Sync documentation from docs/ to the Gitea wiki |
|
||||
| `devx ci validate-commit-msg` | Validate commit messages for conventional format |
|
||||
|
||||
### `devx tools` — Developer tools
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `devx tools check-test-speed` | Run unit tests and enforce execution-time budgets |
|
||||
| `devx tools configure-repo` | Configure branch protection and labels via Gitea API |
|
||||
| `devx tools generate-badges` | Generate self-contained SVG badge files |
|
||||
| `devx tools generate-cliff-config` | Generate a cliff.toml with the correct task ID prefix |
|
||||
| `devx tools install-checkmake` | Install checkmake (Makefile linter) |
|
||||
| `devx tools install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `devx tools setup` | Project setup: install deps, hooks, tea login |
|
||||
|
||||
### `devx molecule` — Molecule testing (optional)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
||||
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
||||
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
|
||||
| `devx molecule guard` | Run molecule tests with CI failure polling |
|
||||
|
||||
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
||||
in the wiki for full command documentation with examples.
|
||||
|
||||
## Configuration
|
||||
|
||||
devx reads configuration from environment variables with `.env` file fallback.
|
||||
The config system loads `.env` automatically via `python-dotenv`.
|
||||
|
||||
### 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_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
|
||||
| `DEVX_TRANSLATIONS_PATH` | — | Path to a custom JSON translations file |
|
||||
| `DEVX_VERSION_FILE` | `src/devx/__init__.py` | Version source file (used by release) |
|
||||
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
|
||||
| `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
|
||||
| `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
| `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) |
|
||||
|
||||
### 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. Copy
|
||||
`.env.example` to `.env` and fill in your tokens:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Change classification
|
||||
|
||||
Projects configure which file paths are infrastructure (no release needed) vs
|
||||
user-facing (release needed) in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.devx.classify]
|
||||
# Merge with DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs, config)
|
||||
# use_defaults = true # (default)
|
||||
|
||||
# Project-specific infrastructure paths (merged with defaults)
|
||||
infrastructure = []
|
||||
|
||||
# Files that would default to user-facing but are actually infrastructure
|
||||
infrastructure_overrides = [
|
||||
"src/myproject/__init__.py", # only contains __version__
|
||||
]
|
||||
|
||||
# Safety override for broad infrastructure patterns
|
||||
user_facing_overrides = []
|
||||
|
||||
# Tag patterns for CI conditional execution (orthogonal to release impact)
|
||||
[tool.devx.classify.tags]
|
||||
# ansible = ["ansible/**"]
|
||||
```
|
||||
|
||||
## 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
|
||||
make test-unit # Unit tests without coverage
|
||||
make workflow-check # Static + dry-run validation of workflow YAML
|
||||
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`
|
||||
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env`)
|
||||
|
||||
### Make targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make setup` | Full local development setup (venv, deps, hooks, CI tools) |
|
||||
| `make setup-ci` | Lean setup for CI jobs (pytest + lint + runtime deps) |
|
||||
| `make setup-quality` | Setup for quality job (lint + test deps, actionlint) |
|
||||
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
|
||||
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `make install-hooks` | Install git hooks (pre-commit, pre-push) |
|
||||
| `make lint` | ruff check + ruff format check + pyright + bandit |
|
||||
| `make lint-ruff` | ruff check only |
|
||||
| `make lint-format` | ruff format check only |
|
||||
| `make typecheck` | pyright only |
|
||||
| `make lint-bandit` | bandit security scan only |
|
||||
| `make lint-all` | lint + workflow-lint (actionlint) |
|
||||
| `make lint-deps` | pip-audit dependency vulnerability scan |
|
||||
| `make test-unit` | Unit tests without coverage |
|
||||
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
|
||||
| `make workflow-lint` | actionlint on .gitea/workflows/*.yml |
|
||||
| `make workflow-dryrun` | act_runner exec --dryrun on all workflows |
|
||||
| `make workflow-check` | workflow-lint + workflow-dryrun |
|
||||
| `make clean` | Remove caches, build artifacts, coverage data |
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and
|
||||
architecture details.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
devx is a self-contained Python package under `src/devx/`. It never imports
|
||||
from scripts outside the package. All tools are invoked via
|
||||
`python -m devx.ci.*`, `python -m devx.tools.*`, or `python -m devx.molecule.*`.
|
||||
|
||||
```
|
||||
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 (DevxError, APIError)
|
||||
├── opentofu.py # OpenTofu output helpers
|
||||
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
```
|
||||
|
||||
### 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
|
||||
- **100% test coverage** — enforced by `--cov-fail-under=100`
|
||||
- **i18n by default** — all user-facing strings wrapped in `_()` for translation
|
||||
|
||||
See [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/Architecture)
|
||||
and [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CI-CD-Workflow)
|
||||
in the wiki for detailed documentation.
|
||||
|
||||
## Links
|
||||
|
||||
- **Wiki**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
- **Releases**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
- **Actions**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
- **Source**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx](https://git.oblachno.oblachno.fyi/oblachno-oss/devx)
|
||||
- **GRM (origin project)**: [https://git.oblachno.oblachno.fyi/oblachno-oss/grm](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env fish
|
||||
set -l script_dir (dirname (status --current-filename))
|
||||
source "$script_dir/.venv/bin/activate.fish"
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,174 @@
|
||||
# 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, documentation coverage,
|
||||
parallel test distribution, 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 repositories.
|
||||
|
||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
devx provides a complete, opinionated CI/CD pipeline for any project hosted on
|
||||
a Gitea instance with Gitea Actions. Install the package, declare configuration
|
||||
via environment variables and `pyproject.toml`, and inherit:
|
||||
|
||||
- **Automated releases** — git-cliff-driven semver versioning, changelog
|
||||
generation, tagging, and publishing to a Gitea PyPI registry.
|
||||
- **PR automation** — squash-merge with task ID validation, automated PR
|
||||
review with inline comments, and conventional commit enforcement.
|
||||
- **Smart change classification** — user-facing vs workflow-only change
|
||||
detection so infrastructure-only changes skip releases.
|
||||
- **Documentation sync** — push `docs/` markdown to the Gitea wiki with
|
||||
integrity verification.
|
||||
- **Quality badges** — self-contained SVG badges for coverage, tests, docs,
|
||||
quality, version, and Python version.
|
||||
- **Translation checks** — validate i18n keys against source code, detect
|
||||
dead keys and missing languages.
|
||||
- **Parallel test distribution** — split test files or molecule scenarios
|
||||
across CI runners with cross-runner fail-fast.
|
||||
- **Developer tools** — environment setup, CI tool installation, test speed
|
||||
enforcement, repository configuration.
|
||||
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
|
||||
Chinese, and Polish; projects can extend with their own keys.
|
||||
|
||||
## Installation
|
||||
|
||||
devx is published to the Gitea PyPI registry at
|
||||
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
|
||||
The registry is publicly readable — no authentication required to install.
|
||||
|
||||
### Quick install (one-off)
|
||||
|
||||
```bash
|
||||
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
### Persistent configuration (recommended)
|
||||
|
||||
Add the registry to `~/.pip/pip.conf`:
|
||||
|
||||
```ini
|
||||
[global]
|
||||
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
Then `pip install devx` works without specifying `--index-url`.
|
||||
|
||||
### As a dependency in another project
|
||||
|
||||
Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.11.1",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
```bash
|
||||
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
|
||||
pip install "devx[molecule]" # Molecule testing for Ansible projects
|
||||
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
devx is a self-contained Python package under `src/devx/`:
|
||||
|
||||
- **Core modules** — `config.py`, `exceptions.py`, `i18n.py`, `api_clients.py`,
|
||||
`gitea_cli.py`, `cli.py`, `opentofu.py`
|
||||
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
|
||||
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
|
||||
validate_commit_msg, detect_release_commit, notify_failure, post_merge,
|
||||
discover_runners, distribute_files, integration_guard
|
||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
|
||||
start_docker, platforms
|
||||
|
||||
See [Architecture](Architecture) for the full package structure, module
|
||||
descriptions, design principles, and data flow diagrams.
|
||||
|
||||
## CI/CD pipeline
|
||||
|
||||
devx uses Gitea Actions with three workflows:
|
||||
|
||||
- **CI** (`ci.yml`) — runs on pull requests: quality checks, change detection,
|
||||
release dry-run, automated PR review, and auto-merge.
|
||||
- **Post-merge** (`post-merge.yml`) — runs on every push to master: release
|
||||
versioning, wiki sync, badge generation, Vikunja task updates, and repo
|
||||
configuration.
|
||||
- **Publish** (`publish.yml`) — runs on tag pushes: builds the package,
|
||||
publishes to the Gitea PyPI registry, and creates a Gitea release.
|
||||
|
||||
See [CI/CD Workflow](CI-CD-Workflow) for the full pipeline documentation,
|
||||
including the post-merge job graph, release process, badge generation, and
|
||||
wiki sync details.
|
||||
|
||||
## CLI commands
|
||||
|
||||
devx provides a `devx` CLI with three command groups:
|
||||
|
||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||
- `devx tools <command>` — Developer tools (7 commands)
|
||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||
|
||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||
|
||||
## Configuration
|
||||
|
||||
devx reads configuration from `DEVX_*` environment variables with `.env` file
|
||||
fallback. Key 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_REPO_OWNER` | **(must be set)** | Repository owner |
|
||||
| `DEVX_REPO_NAME` | **(must be set)** | Repository name |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
|
||||
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
|
||||
for the full configuration reference, PR workflow, and project conventions.
|
||||
|
||||
## Wiki pages
|
||||
|
||||
- [Home](Home) — This page
|
||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||
|
||||
## Links
|
||||
|
||||
- **Source**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx](https://git.oblachno.oblachno.fyi/oblachno-oss/devx)
|
||||
- **Releases**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
- **Actions**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
- **GRM (origin project)**: [https://git.oblachno.oblachno.fyi/oblachno-oss/grm](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
# Architecture
|
||||
|
||||
devx is a reusable Python package providing development and CI/CD tools for
|
||||
oblachno-oss projects. It is self-contained under `src/devx/` and never imports
|
||||
from scripts outside the package.
|
||||
|
||||
## 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 (JSON-based, translations.json)
|
||||
├── exceptions.py # Custom exception types (DevxError, APIError)
|
||||
├── opentofu.py # OpenTofu output helpers
|
||||
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
│ ├── __init__.py
|
||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||
│ ├── release.py # Automated versioning, tagging, changelog
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry
|
||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||
│ ├── 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
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── __init__.py
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks, tea login)
|
||||
│ ├── 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
|
||||
│ ├── generate_cliff_config.py # Generate cliff.toml with correct prefix
|
||||
│ └── install_checkmake.py # Install checkmake (Makefile linter)
|
||||
└── molecule/ # Optional molecule testing helpers (Ansible projects)
|
||||
├── __init__.py
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── distribute_molecule.py # Distribute scenarios across runners
|
||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast
|
||||
├── molecule_all.py # Run all molecule scenarios locally
|
||||
├── start_docker.py # Ensure Docker is available for molecule
|
||||
└── platforms.py # Supported molecule platforms
|
||||
```
|
||||
|
||||
## Core modules
|
||||
|
||||
### `__init__.py`
|
||||
|
||||
Contains only `__version__`, the single source of truth for the package
|
||||
version. Read by setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
Updated automatically by `devx.ci.release` during the release process. Treated
|
||||
as infrastructure (not user-facing) by the change classifier since it is a
|
||||
release artifact, not user code.
|
||||
|
||||
### `cli.py`
|
||||
|
||||
Click-based CLI entry point. Provides three command groups: `devx ci`,
|
||||
`devx tools`, and `devx molecule`. Each subcommand delegates to the
|
||||
corresponding module via `_run_module()`, which imports the module, sets
|
||||
`sys.argv`, and calls its `main()` function. This design keeps all logic in
|
||||
the modules themselves — `cli.py` is purely a router.
|
||||
|
||||
The CLI is registered as a console script via `pyproject.toml`:
|
||||
```toml
|
||||
[project.scripts]
|
||||
devx = "devx.cli:cli"
|
||||
```
|
||||
|
||||
### `config.py`
|
||||
|
||||
Shared configuration constants for all devx modules. All defaults can be
|
||||
overridden via environment variables with the `DEVX_` prefix. Provides:
|
||||
|
||||
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
|
||||
- `REPO_OWNER` — repository owner (must be set per-project)
|
||||
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (e.g., `DEVX-N`)
|
||||
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
|
||||
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
|
||||
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
|
||||
- `CONVENTIONAL_RE` — conventional commit format regex
|
||||
|
||||
### `exceptions.py`
|
||||
|
||||
Custom exception hierarchy:
|
||||
|
||||
- `DevxError` — base exception for all devx errors
|
||||
- `APIError(DevxError)` — raised when a REST API call returns an HTTP error.
|
||||
Carries `status` (HTTP status code) and `message` (error message).
|
||||
|
||||
### `i18n.py`
|
||||
|
||||
Simple i18n system using a JSON translations file (`translations.json`).
|
||||
Supports five languages: `en`, `bg`, `de`, `ru`, `zh`. The `_()` function
|
||||
wraps user-facing strings for translation.
|
||||
|
||||
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
||||
custom JSON file. Keys from the project's file are merged on top of devx's
|
||||
built-in translations, allowing projects to override or add keys without
|
||||
modifying the package.
|
||||
|
||||
### `api_clients.py`
|
||||
|
||||
Reusable HTTP API clients with connection pooling and retry logic. Both
|
||||
clients retry on transient errors (429, 5xx, connection errors) with
|
||||
exponential backoff (2s, 4s, 8s).
|
||||
|
||||
**`GiteaClient`** — Gitea REST API wrapper:
|
||||
- Branch protection (get, create, update)
|
||||
- Labels (list, create, add to issues)
|
||||
- Issues (create, list)
|
||||
- Pull requests (get commits, merge, create review)
|
||||
- Releases (list)
|
||||
- Wiki pages (list, fetch, create, update, delete)
|
||||
|
||||
**`VikunjaClient`** — Vikunja REST API wrapper:
|
||||
- Tasks (list project tasks, get, update, mark done)
|
||||
- Comments (create)
|
||||
|
||||
### `gitea_cli.py`
|
||||
|
||||
Thin Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for
|
||||
structured data. Used by CI scripts for Gitea API operations that tea handles
|
||||
well, avoiding hand-rolled HTTP requests.
|
||||
|
||||
**`TeaCLI`** operations:
|
||||
- `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
|
||||
- `list_branches()` — Branch listing
|
||||
|
||||
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
|
||||
|
||||
### `opentofu.py`
|
||||
|
||||
OpenTofu output helpers for CI/CD deployment scripts. Provides reusable
|
||||
functions for extracting values from `tofu output` in a structured way,
|
||||
eliminating duplicated `subprocess.run` boilerplate:
|
||||
|
||||
- `get_tofu_output(output_name, cwd, env)` — Run `tofu output -json` and return parsed JSON
|
||||
- `get_tofu_vm_ip(output_name, vm_name, cwd, env)` — Extract a VM's IP address
|
||||
- `get_tofu_vm_field(output_name, vm_name, field, cwd, env)` — Extract a VM field
|
||||
|
||||
## CI/CD modules (`devx.ci`)
|
||||
|
||||
Modules in this package are run by Gitea Actions workflows. They may import
|
||||
from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`.
|
||||
|
||||
### `release.py`
|
||||
|
||||
Automated release using git-cliff. Calculates the next semver version from
|
||||
conventional commits since the last tag, updates `__version__` in
|
||||
`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release
|
||||
is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated
|
||||
tag, and pushes both to master.
|
||||
|
||||
Idempotent: if there are no new conventional commits since the last tag, it
|
||||
exits without doing anything. If the tag already exists, it skips tag creation
|
||||
and only pushes. Includes a `--verify` mode that checks tag/version/changelog
|
||||
alignment without making changes.
|
||||
|
||||
### `publish.py`
|
||||
|
||||
Builds the Python package with `python -m build`, publishes to a Gitea PyPI
|
||||
registry (or standard PyPI if `PYPI_TOKEN` is set), and creates a Gitea
|
||||
release with git-cliff-generated notes. Supports `--skip-build` for non-Python
|
||||
repos that only need a Gitea release.
|
||||
|
||||
### `auto_merge.py`
|
||||
|
||||
Auto-merges a PR when all CI checks pass. Reads the task ID from the branch
|
||||
name, validates the PR title format against
|
||||
the Vikunja task title, extracts the conventional commit message from PR
|
||||
commits, and squash-merges with title `{PREFIX}-N <conventional commit>`.
|
||||
|
||||
If the head branch is behind master (HTTP 405), it automatically pulls master,
|
||||
rebases, force-pushes, and retries the merge.
|
||||
|
||||
### `classify_changes.py`
|
||||
|
||||
Classifies git changes between two refs as user-facing or workflow-only. Uses
|
||||
a layered rule system configured in `pyproject.toml` under
|
||||
`[tool.devx.classify]`:
|
||||
|
||||
1. **User-facing overrides** (highest priority — safety override)
|
||||
2. **Infrastructure overrides** (explicit per-file)
|
||||
3. **Infrastructure patterns** (DEFAULT_INFRASTRUCTURE + project-specific)
|
||||
4. **Default**: user-facing (safe default — any unknown file triggers release)
|
||||
|
||||
Also supports custom tags (orthogonal to release impact) for CI conditional
|
||||
execution (e.g., `ansible` tag to trigger molecule tests).
|
||||
|
||||
### `pr_review.py`
|
||||
|
||||
Automated PR review. Fetches the PR diff via the Gitea API and runs a series
|
||||
of checks, posting a structured 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)
|
||||
|
||||
### `sync_wiki.py`
|
||||
|
||||
Syncs documentation from `docs/` to the Gitea wiki via the API. Reads
|
||||
`docs/mapping.json` to map file paths to wiki page titles, then creates or
|
||||
updates pages. Supports `--dry-run`, `--verify` (check content), and
|
||||
`--strict` (full integrity check: page count, missing pages, stale pages,
|
||||
content match).
|
||||
|
||||
### `push_badges.py`
|
||||
|
||||
Generates SVG badge files using `devx.tools.generate_badges`, pushes them to
|
||||
an orphan `badges` branch, and updates `README.md` and `docs/index.md` on
|
||||
master with cache-busting `raw/commit/<sha>/badge.svg` URLs (Gitea caches
|
||||
`raw/branch/` URLs for 6 hours). Fetches latest master before generating
|
||||
badges so the version badge reflects the current state. Supports `--retries`
|
||||
for retrying on git push failures.
|
||||
|
||||
### `notify_failure.py`
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue
|
||||
creation with failure labels. Supports `--auto-login` to configure the tea
|
||||
CLI login profile from `REPO_TOKEN` and `DEVX_GITEA_API_URL` before creating
|
||||
the issue.
|
||||
|
||||
### `post_merge.py`
|
||||
|
||||
Updates the Vikunja task after a merge to master. Extracts the task ID from
|
||||
the commit message, marks the task as done, and posts a comment with the
|
||||
merge SHA.
|
||||
|
||||
### `validate_commit_msg.py`
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only
|
||||
(no `{PREFIX}-N` prefix). On master: must have `{PREFIX}-N` prefix from
|
||||
auto-merge, followed by a conventional commit message.
|
||||
|
||||
### `detect_release_commit.py`
|
||||
|
||||
Detects whether the latest git commit is a release commit
|
||||
(`release: vX.Y.Z [skip ci]`). Writes `is-release=true` or `is-release=false`
|
||||
to `$GITHUB_OUTPUT` for use in CI workflow conditionals.
|
||||
|
||||
### `check_translations.py`
|
||||
|
||||
Validates translation files against the Python source code. Checks for
|
||||
missing keys (used in code but not in translations), dead keys (defined but
|
||||
not used), and missing languages (a key exists but is missing one of the five
|
||||
supported languages). Supports checking additional translation sets via
|
||||
`--translations`.
|
||||
|
||||
### `doc_coverage.py`
|
||||
|
||||
Checks documentation coverage for CLI commands and major modules. Parses
|
||||
Click commands from `cli.py` and verifies each has documentation in
|
||||
`docs/user/cli-commands.md`. Checks that core modules are documented in
|
||||
`architecture.md` and CI scripts in `ci-cd-workflow.md`. Supports
|
||||
`--fail-on-missing` to enforce 100% coverage.
|
||||
|
||||
### `discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners at three levels: repository,
|
||||
organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo
|
||||
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
|
||||
array for use as a dynamic matrix in Gitea Actions.
|
||||
|
||||
### `distribute_files.py`
|
||||
|
||||
Distributes files matching a glob pattern across N parallel runners
|
||||
(round-robin). Writes the assigned file list for the current runner to
|
||||
`$GITHUB_ENV`. Used for splitting test suites across CI runners.
|
||||
|
||||
### `integration_guard.py`
|
||||
|
||||
Runs pytest with the same cross-runner failure detection 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.
|
||||
|
||||
## Developer tools (`devx.tools`)
|
||||
|
||||
Modules in this package are run locally or by CI setup jobs. They may import
|
||||
from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
|
||||
|
||||
### `setup.py`
|
||||
|
||||
Project setup: installs Python dependencies (editable mode with extras),
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
|
||||
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
|
||||
profile from `.env`. Supports `--extras` to specify dependency groups,
|
||||
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
|
||||
configuration.
|
||||
|
||||
### `install_tools.py`
|
||||
|
||||
Installs CI/CD development tools that are not Python packages: actionlint,
|
||||
git-cliff, act_runner, and tea. Each tool is installed to `~/.local/bin` if
|
||||
not already on PATH. Idempotent: skips tools that are already available.
|
||||
Supports `--tool` to install specific tools and `--list` to show status.
|
||||
|
||||
### `check_test_speed.py`
|
||||
|
||||
Runs unit tests and enforces execution-time budgets. Two quality gates:
|
||||
total suite time must not exceed `--max-seconds` (default: 10s), and no
|
||||
individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to
|
||||
disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`.
|
||||
|
||||
### `configure_repo.py`
|
||||
|
||||
Configures repository branch protection and labels via the Gitea REST API.
|
||||
Sets up master branch protection (required status checks, block on rejected
|
||||
reviews, block on outdated branch) and creates standard labels. Status check
|
||||
contexts are read from `DEVX_STATUS_CHECKS` or default to
|
||||
`CI / quality (pull_request)`.
|
||||
|
||||
### `generate_badges.py`
|
||||
|
||||
Generates self-contained SVG badge files from project metrics. Runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files that can be served as static files from the Gitea raw file API.
|
||||
Badges generated: coverage, tests, docs, quality, version, python.
|
||||
|
||||
### `generate_cliff_config.py`
|
||||
|
||||
Generates a `cliff.toml` configuration file with the correct task ID prefix
|
||||
preprocessor. Eliminates the need to manually duplicate and maintain
|
||||
`cliff.toml` across repos that use devx. Supports `--prefix` to set the task
|
||||
ID prefix and `--force` to overwrite an existing file.
|
||||
|
||||
### `install_checkmake.py`
|
||||
|
||||
Installs checkmake (Makefile linter) if not already present. Tries
|
||||
`go install` first if Go is available, otherwise downloads the latest
|
||||
pre-built Linux binary from the official GitHub releases.
|
||||
|
||||
## Molecule modules (`devx.molecule`)
|
||||
|
||||
Optional modules for projects with Ansible roles. Requires the `molecule`
|
||||
extra (`pip install devx[molecule]`).
|
||||
|
||||
### `distribute_molecule.py`
|
||||
|
||||
Distributes molecule (scenario, platform) pairs across N parallel runners.
|
||||
Discovers scenarios under `ansible/roles/*/molecule/` and crosses them with
|
||||
the supported OS platform matrix. Supports `--roles-root` for multi-role
|
||||
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
||||
platforms.
|
||||
|
||||
### `molecule_ci_guard.py`
|
||||
|
||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||
failures. If any other molecule matrix runner reports failure, the current
|
||||
molecule subprocess is killed and this runner exits early. Supports both
|
||||
single-role (4-part) and multi-role (5-part) pair encoding.
|
||||
|
||||
### `molecule_all.py`
|
||||
|
||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||
Intended for local development; CI uses the parallel matrix instead.
|
||||
|
||||
### `discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners for molecule tests. Same logic as
|
||||
`devx.ci.discover_runners` but intended for molecule-specific workflows.
|
||||
|
||||
### `start_docker.py`
|
||||
|
||||
Ensures Docker is available for molecule tests in CI. Verifies Docker is
|
||||
accessible and sets `DOCKER_HOST` explicitly. If the host socket is not
|
||||
available, tries the rootless socket, then starts a local `dockerd` with the
|
||||
vfs storage driver (requires privileged container).
|
||||
|
||||
### `platforms.py`
|
||||
|
||||
Single source of truth for the 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. Supports loading custom platforms from a JSON file.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Self-contained package** — `src/devx/` never imports from scripts outside
|
||||
the package. This allows devx to be installed and used as a dependency
|
||||
without requiring a specific repo layout in the consumer.
|
||||
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*`,
|
||||
`python -m devx.tools.*`, or `python -m devx.molecule.*`. The `devx` CLI is
|
||||
a thin router that delegates to module `main()` functions.
|
||||
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (not `.:src` since
|
||||
there are no scripts at repo root). The `src` directory is the sole import
|
||||
root.
|
||||
- **Config via env vars** — `DEVX_*` environment variables with `.env` file
|
||||
fallback. Projects override defaults via environment or `.env`, never by
|
||||
editing package code.
|
||||
- **100% test coverage** — enforced by `--cov-fail-under=100` in pytest.
|
||||
- **i18n by default** — all user-facing strings wrapped in `_()` for
|
||||
translation. Five languages supported out of the box.
|
||||
- **Safe-by-default classification** — any file that doesn't match an
|
||||
infrastructure pattern defaults to user-facing, triggering a release. This
|
||||
prevents new file types from accidentally skipping releases.
|
||||
- **Secrets via environment** — secrets are passed via environment variables,
|
||||
never on the command line.
|
||||
|
||||
## 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 (e.g., `release.py` imports from
|
||||
`classify_changes.py`)
|
||||
|
||||
## Data flow
|
||||
|
||||
### PR lifecycle
|
||||
|
||||
```
|
||||
Developer creates Vikunja task (DEVX-N)
|
||||
│
|
||||
▼
|
||||
Developer creates branch (DEVX-N-short-description)
|
||||
│
|
||||
▼
|
||||
Developer commits (conventional commits, no DEVX-N prefix)
|
||||
│
|
||||
▼
|
||||
Developer pushes and creates PR (title: "DEVX-N: <vikunja task title>")
|
||||
│
|
||||
▼
|
||||
CI workflow (ci.yml) triggers:
|
||||
│
|
||||
├── quality (lint, tests, coverage, test speed, doc coverage,
|
||||
│ translation check, dependency scan, workflow dry-run)
|
||||
│
|
||||
├── detect-changes (classify_changes.py → user-facing or workflow-only)
|
||||
│ └── if user-facing → release-dry-run (release.py --dry-run)
|
||||
│
|
||||
├── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
|
||||
│
|
||||
└── auto-merge (auto_merge.py)
|
||||
├── validate PR title format
|
||||
├── validate PR title matches Vikunja task title
|
||||
├── extract conventional commit message from PR commits
|
||||
├── squash-merge with "DEVX-N <conventional commit>" title
|
||||
└── push to master
|
||||
│
|
||||
▼
|
||||
Post-merge workflow triggers (see below)
|
||||
```
|
||||
|
||||
### Post-merge flow
|
||||
|
||||
```
|
||||
Push to master (squash-merge commit: "DEVX-N <conventional commit>")
|
||||
│
|
||||
▼
|
||||
Post-merge workflow (post-merge.yml) triggers:
|
||||
│
|
||||
├── detect-type (detect_release_commit.py)
|
||||
│ └── is-release? → skip all jobs except badges
|
||||
│
|
||||
├── validate-commit-msg (validate_commit_msg.py --branch master)
|
||||
│
|
||||
├── release (release.py)
|
||||
│ ├── classify_changes.py → skip if workflow-only
|
||||
│ ├── git-cliff → calculate next version
|
||||
│ ├── update __version__ in __init__.py
|
||||
│ ├── update CHANGELOG.md
|
||||
│ ├── run make lint-ruff && make pytest-cov
|
||||
│ ├── commit "release: vX.Y.Z [skip ci]"
|
||||
│ ├── create annotated tag vX.Y.Z
|
||||
│ └── push commit + tag to master
|
||||
│ │
|
||||
│ ▼
|
||||
│ Tag push triggers publish workflow (see below)
|
||||
│
|
||||
├── sync-wiki (sync_wiki.py --strict)
|
||||
│ └── sync docs/ to Gitea wiki with integrity check
|
||||
│
|
||||
├── badges (push_badges.py) [ALWAYS runs, even on release commits]
|
||||
│ ├── fetch latest master
|
||||
│ ├── generate_badges.py → SVG files
|
||||
│ ├── push to orphan badges branch
|
||||
│ └── update README.md + docs/index.md with cache-busting URLs
|
||||
│
|
||||
├── vikunja (post_merge.py)
|
||||
│ ├── extract task ID from commit message
|
||||
│ ├── mark Vikunja task as done
|
||||
│ └── post comment with merge SHA
|
||||
│
|
||||
└── configure-repo (configure_repo.py)
|
||||
└── ensure branch protection and labels
|
||||
```
|
||||
|
||||
### Publish flow
|
||||
|
||||
```
|
||||
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
|
||||
│
|
||||
▼
|
||||
├── install build, twine, git-cliff, tea
|
||||
├── configure tea login
|
||||
│
|
||||
└── publish (publish.py)
|
||||
├── build package (python -m build)
|
||||
├── publish to Gitea PyPI registry (twine upload)
|
||||
│ OR publish to standard PyPI (if PYPI_TOKEN set)
|
||||
│ OR skip publish (if --skip-build)
|
||||
└── create Gitea release with git-cliff notes
|
||||
```
|
||||
|
||||
### Badge generation flow
|
||||
|
||||
```
|
||||
push_badges.py:
|
||||
│
|
||||
├── fetch_latest_master() → git fetch + reset --hard origin/master
|
||||
│
|
||||
├── generate_badges() → devx.tools.generate_badges
|
||||
│ ├── run pytest-cov → parse coverage %
|
||||
│ ├── run pytest → parse test count
|
||||
│ ├── run doc_coverage → parse doc coverage %
|
||||
│ ├── run lint → quality status
|
||||
│ ├── read __version__ from __init__.py
|
||||
│ └── write SVG files to .badges/
|
||||
│
|
||||
├── push_to_badges_branch()
|
||||
│ ├── git checkout --orphan badges
|
||||
│ ├── git rm -rf .
|
||||
│ ├── copy SVG files to root
|
||||
│ ├── git commit "Update badges [skip ci]"
|
||||
│ ├── git push origin badges --force
|
||||
│ └── return commit SHA
|
||||
│
|
||||
└── update_readme_with_badge_sha()
|
||||
├── git checkout master
|
||||
├── replace raw/branch/badges/ URLs with raw/commit/<sha>/ URLs
|
||||
├── git commit "chore: update badge URLs [skip ci]"
|
||||
└── git push origin master
|
||||
```
|
||||
|
||||
## tea CLI integration
|
||||
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
|
||||
reliable, official support. 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` wraps tea with JSON output parsing. Operations that
|
||||
tea does not support (wiki management, commit status, runner discovery,
|
||||
detailed branch protection) fall back to `GiteaClient` (direct HTTP).
|
||||
|
||||
## Version source
|
||||
|
||||
The version source is `__version__` in `src/devx/__init__.py`, read by
|
||||
setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release
|
||||
script updates this file, commits it, and tags the commit. This ensures the
|
||||
package version, git tag, and changelog always stay aligned.
|
||||
@@ -0,0 +1,543 @@
|
||||
# CI/CD Workflow
|
||||
|
||||
devx uses Gitea Actions for CI/CD automation. Three workflows implement a
|
||||
complete pipeline: pull request validation, post-merge release automation, and
|
||||
tag-triggered publishing.
|
||||
|
||||
## Workflow overview
|
||||
|
||||
```
|
||||
PR opened/synchronized ──► CI (ci.yml)
|
||||
│ ├── quality
|
||||
│ ├── detect-changes
|
||||
│ ├── release-dry-run (if user-facing)
|
||||
│ ├── pr-review
|
||||
│ └── auto-merge ──► squash-merge to master
|
||||
│ │
|
||||
▼ ▼
|
||||
Push to master ──► Post-merge (post-merge.yml)
|
||||
├── detect-type
|
||||
├── validate-commit-msg
|
||||
├── release ──► tag vX.Y.Z
|
||||
├── sync-wiki │
|
||||
├── badges │
|
||||
├── vikunja │
|
||||
└── configure-repo │
|
||||
│
|
||||
▼
|
||||
Tag push (v*) ──► Publish (publish.yml)
|
||||
└── publish ──► Gitea PyPI registry + Gitea release
|
||||
```
|
||||
|
||||
## CI workflow (`ci.yml`)
|
||||
|
||||
Runs on pull requests (opened and synchronize) and manual dispatch.
|
||||
|
||||
### Jobs
|
||||
|
||||
#### `quality`
|
||||
|
||||
The main quality gate. Runs on every PR:
|
||||
|
||||
1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
|
||||
(via `make lint-all`)
|
||||
2. **Unit tests with 100% coverage** — `make pytest-cov`
|
||||
3. **Check unit test speed** — `python -m devx.tools.check_test_speed
|
||||
--max-seconds 4 --max-single-seconds 0.5`
|
||||
4. **Documentation coverage check** — `python -m devx.ci.doc_coverage
|
||||
--fail-on-missing`
|
||||
5. **Translation completeness check** — `python -m devx.ci.check_translations`
|
||||
6. **Dependency security scan** — `pip-audit --desc --skip-editable`
|
||||
(best-effort, non-blocking)
|
||||
7. **Workflow dry-run validation** — `make workflow-dryrun` via act_runner
|
||||
(best-effort, skipped if act_runner is not installed)
|
||||
|
||||
#### `detect-changes`
|
||||
|
||||
Classifies changes between `origin/master` and the PR head as user-facing or
|
||||
workflow-only using `python -m devx.ci.classify_changes --github-output`.
|
||||
Writes `user-facing-changed=true|false` to the job output for use by
|
||||
downstream jobs.
|
||||
|
||||
#### `release-dry-run`
|
||||
|
||||
Depends on `quality` and `detect-changes`. Only runs if user-facing changes
|
||||
are detected. Runs `python -m devx.ci.release --dry-run` to validate that
|
||||
the release script can calculate the next version and generate the changelog
|
||||
without making changes. Non-blocking (uses `|| true`).
|
||||
|
||||
#### `pr-review`
|
||||
|
||||
Runs on every pull request. Executes `python -m devx.ci.pr_review` with the
|
||||
PR number and repository. Fetches the PR diff via the Gitea API and runs
|
||||
automated checks, posting a structured review:
|
||||
|
||||
- `COMMENT` — no issues found
|
||||
- `REQUEST_CHANGES` — issues found that must be addressed
|
||||
|
||||
Checks performed:
|
||||
1. Architecture compliance — no subprocess in CLI, no hardcoded URLs
|
||||
2. Best practices — no `print()`, no bare `except`, no `TODO`/`FIXME`,
|
||||
no functions > 50 lines
|
||||
3. Security — no hardcoded secrets, no `shell=True`, no `eval`/`exec`
|
||||
4. i18n — no raw strings in `click.echo()` without `_()` wrapper
|
||||
5. Resource management — no `open()` without `with`, no `Popen()` without
|
||||
cleanup
|
||||
6. Documentation — source changes must include doc updates
|
||||
7. Test coverage — source changes must include test updates
|
||||
8. Commit conventions — conventional commit format on PR commits
|
||||
|
||||
#### `auto-merge`
|
||||
|
||||
Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
|
||||
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
|
||||
title, repository, and PR number:
|
||||
|
||||
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo` → `DEVX-12`)
|
||||
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
|
||||
3. **Validate PR title matches Vikunja task** — fetches the Vikunja task and
|
||||
compares the title
|
||||
4. **Extract conventional commit message** from PR commits (newest matching
|
||||
conventional format)
|
||||
5. **Squash-merge** with title `{PREFIX}-N <conventional commit message>`
|
||||
6. If the head branch is behind master (HTTP 405), automatically pulls master,
|
||||
rebases, force-pushes, and retries the merge
|
||||
|
||||
The merge commit push to master triggers the post-merge workflow.
|
||||
|
||||
### Smart CI: user-facing vs workflow-only changes
|
||||
|
||||
Not all changes require a new release. The `detect-changes` job 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
|
||||
- `src/devx/__init__.py` — Only contains `__version__` (release artifact)
|
||||
|
||||
**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
|
||||
|
||||
Classification is configured in `pyproject.toml` under
|
||||
`[tool.devx.classify]`. The framework provides `DEFAULT_INFRASTRUCTURE` — a
|
||||
curated list of paths that are infrastructure for any Python project. Projects
|
||||
inherit these automatically and only specify what is different.
|
||||
|
||||
Rule priority (first match wins):
|
||||
1. `user_facing_overrides` — safety override (highest priority)
|
||||
2. `infrastructure_overrides` — explicit per-file
|
||||
3. `infrastructure` — DEFAULT_INFRASTRUCTURE + project-specific patterns
|
||||
4. Default: user-facing (safe — any unknown file triggers release)
|
||||
|
||||
## Post-merge workflow (`post-merge.yml`)
|
||||
|
||||
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 ──┬── validate-commit-msg (skip if release commit)
|
||||
├── release (skip if release commit)
|
||||
│ │
|
||||
│ ├── sync-wiki (needs release)
|
||||
│ ├── badges (needs release, ALWAYS runs)
|
||||
│ └── vikunja (needs release)
|
||||
└── configure-repo (independent, skip if release commit)
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
The `badges` job uses `if: always()` with no is-release condition so it runs
|
||||
on every push to master, including release commits. This ensures badges
|
||||
(tests, coverage, version, etc.) are always current.
|
||||
|
||||
When `release` creates a `release: vX.Y.Z` commit, the release commit's
|
||||
post-merge run still updates badges (the version badge picks up the new
|
||||
version). Other jobs skip. The tag push triggers `publish.yml`.
|
||||
|
||||
### Jobs
|
||||
|
||||
#### `detect-type`
|
||||
|
||||
Checks if the latest commit is a release commit (`release: vX.Y.Z [skip ci]`)
|
||||
using `python -m devx.ci.detect_release_commit`. Writes `is-release=true` or
|
||||
`is-release=false` to the job output. All subsequent jobs use this to
|
||||
conditionally skip for release commits.
|
||||
|
||||
#### `validate-commit-msg`
|
||||
|
||||
Depends on `detect-type`. Skips for release commits. Validates the latest
|
||||
commit message using `python -m devx.ci.validate_commit_msg --branch master`.
|
||||
On master, commits must follow `{PREFIX}-N: <conventional commit>` format
|
||||
(added by auto-merge).
|
||||
|
||||
#### `release`
|
||||
|
||||
Depends on `detect-type`. Skips for release commits. The core release
|
||||
automation job. Runs `python -m devx.ci.release`:
|
||||
|
||||
1. **Classify changes** — calls `classify_changes.py` to check for user-facing
|
||||
changes. If only infrastructure files changed, exits without releasing.
|
||||
2. **Calculate next version** — uses git-cliff to determine the next semver
|
||||
version from conventional commits since the last tag
|
||||
3. **Update version file** — updates `__version__` in `src/devx/__init__.py`
|
||||
4. **Update changelog** — prepends the new version section to `CHANGELOG.md`
|
||||
using git-cliff output
|
||||
5. **Run tests** — executes `make lint-ruff` and `make pytest-cov` to verify
|
||||
the release is healthy. If either fails, the release is aborted — no
|
||||
commit, no tag. Use `--skip-tests` only for emergency releases.
|
||||
6. **Commit** — stages the version file and changelog, commits with
|
||||
`release: vX.Y.Z [skip ci]` (uses `--no-verify` to bypass the commit-msg
|
||||
hook since release commits are a special case)
|
||||
7. **Create tag** — creates an annotated tag `vX.Y.Z` with the changelog as
|
||||
the tag message
|
||||
8. **Push** — pushes both the commit and tag to master
|
||||
|
||||
The script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits without doing anything. 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 and ensures
|
||||
tag/version/commit alignment.
|
||||
|
||||
**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) |
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue via
|
||||
`python -m devx.ci.notify_failure`.
|
||||
|
||||
#### `sync-wiki`
|
||||
|
||||
Depends on `detect-type` and `release`. Skips for release commits. Syncs
|
||||
documentation from `docs/` to the Gitea wiki using
|
||||
`python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
|
||||
|
||||
1. Reads `docs/mapping.json` to map file paths to wiki page titles
|
||||
2. Lists existing wiki pages via the Gitea API
|
||||
3. For each mapped file, reads content and creates or updates the wiki page
|
||||
4. `--strict` runs a full integrity check: verifies page count, missing
|
||||
pages, stale pages, and content match. Fails if any page is empty or
|
||||
content doesn't match.
|
||||
|
||||
Pages that exist in the wiki but not in the mapping are left untouched (not
|
||||
deleted).
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
#### `badges`
|
||||
|
||||
Depends on `detect-type` and `release`. Uses `if: always()` so it runs on
|
||||
every push to master, including release commits. Generates and pushes quality
|
||||
badges using `python -m devx.ci.push_badges`:
|
||||
|
||||
1. **Fetch latest master** — `git fetch origin master && git reset --hard
|
||||
origin/master` (ensures the version badge reflects the current state,
|
||||
even if the release job just pushed a new version)
|
||||
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
||||
`version.svg`, `python.svg`
|
||||
3. **Push to badges branch** — creates an orphan `badges` branch, copies SVG
|
||||
files, commits, and force-pushes
|
||||
4. **Update README/docs** — switches back to master, replaces
|
||||
`raw/branch/badges/<name>.svg` URLs with `raw/commit/<sha>/<name>.svg`
|
||||
URLs (cache-busting — Gitea caches `raw/branch/` URLs for 6 hours),
|
||||
commits, and pushes
|
||||
|
||||
Supports `--retries` for retrying on git push failures (fetches latest master
|
||||
and waits 10s between attempts).
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
#### `vikunja`
|
||||
|
||||
Depends on `detect-type` and `release`. Skips for release commits. Updates
|
||||
the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
|
||||
<sha>`:
|
||||
|
||||
1. Extracts the task ID from the first line of the commit message
|
||||
2. Marks the corresponding Vikunja task as done
|
||||
3. Posts a comment with the merge SHA
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
#### `configure-repo`
|
||||
|
||||
Depends on `detect-type`. Skips for release commits. Ensures branch
|
||||
protection and labels are configured using
|
||||
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
|
||||
|
||||
- Sets up master branch protection (required status checks, block on rejected
|
||||
reviews, block on outdated branch)
|
||||
- Creates standard labels
|
||||
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
|
||||
`CI / quality (pull_request)`
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
## Publish workflow (`publish.yml`)
|
||||
|
||||
Runs on tag pushes matching `v*`. Triggered by the `release` job in the
|
||||
post-merge workflow when it creates and pushes a new version tag.
|
||||
|
||||
### Job: `publish`
|
||||
|
||||
1. **Install dependencies** — build, twine, requests, python-dotenv, click,
|
||||
and the project itself
|
||||
2. **Install CI tools** — git-cliff and tea via
|
||||
`python -m devx.tools.install_tools`
|
||||
3. **Configure tea login** — `tea login add` using `REPO_TOKEN`
|
||||
4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`:
|
||||
- Build the package with `python -m build`
|
||||
- Publish to the Gitea PyPI registry (default) using `twine upload
|
||||
--repository-url <url> -u <token> -p <token>`
|
||||
- OR publish to standard PyPI if `PYPI_TOKEN` is set
|
||||
- OR skip publishing if `--skip-build` is passed (non-Python repos)
|
||||
- Create a Gitea release with git-cliff-generated release notes via
|
||||
`tea create release`
|
||||
|
||||
Publishing destination resolution (checked in order):
|
||||
1. **Gitea PyPI registry** — if `--registry-url` is given, or
|
||||
`DEVX_PYPI_REGISTRY_URL` env var is set, or derived from `GITEA_API_URL`
|
||||
2. **Standard PyPI** — if `PYPI_TOKEN` is set (takes precedence over Gitea
|
||||
registry)
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
## CI scripts
|
||||
|
||||
### `auto_merge.py`
|
||||
|
||||
Auto-merge PR when all CI checks pass. Reads task ID from the branch name
|
||||
(e.g., `DEVX-12-fix-foo` → `DEVX-12`). Validates PR title format, checks the
|
||||
Vikunja task exists and the title matches, extracts the conventional commit
|
||||
message from PR commits, and squash-merges with
|
||||
`{PREFIX}-N <conventional commit>` title.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.auto_merge <branch> <pr_title> <owner/repo> <pr_number>
|
||||
```
|
||||
|
||||
### `release.py`
|
||||
|
||||
Automated release using git-cliff. Calculates next semver version from
|
||||
conventional commits, updates `__version__` and `CHANGELOG.md`, runs lint and
|
||||
tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, and
|
||||
pushes. Idempotent — exits if no unreleased changes.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.release [--dry-run] [--skip-tests] [--verify]
|
||||
```
|
||||
|
||||
- `--dry-run` — preview without making changes
|
||||
- `--skip-tests` — skip lint and test verification (emergency only)
|
||||
- `--verify` — check tag/version/changelog alignment and exit
|
||||
|
||||
### `publish.py`
|
||||
|
||||
Builds package, publishes to Gitea PyPI registry or standard PyPI, and
|
||||
creates a Gitea release with git-cliff-generated notes.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.publish <tag> <owner/repo> [--registry-url <url>] [--skip-build]
|
||||
```
|
||||
|
||||
### `pr_review.py`
|
||||
|
||||
Automated PR review. Fetches the PR diff via the Gitea API, runs automated
|
||||
checks (architecture, best practices, security, i18n, resource management,
|
||||
documentation, test coverage, commit conventions), and posts a structured
|
||||
review with inline comments.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
```
|
||||
|
||||
### `notify_failure.py`
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
|
||||
creation with failure labels. Supports `--auto-login` to configure the tea
|
||||
CLI login profile from `REPO_TOKEN`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
|
||||
--workflow <name> --commit <sha> [--auto-login]
|
||||
```
|
||||
|
||||
### `post_merge.py`
|
||||
|
||||
Updates Vikunja task after a merge to master. Extracts task ID from the
|
||||
commit message, marks the task as done, and posts a comment with the merge SHA.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>] [--git-sha <sha>]
|
||||
```
|
||||
|
||||
### `classify_changes.py`
|
||||
|
||||
Classifies git changes as user-facing or workflow-only. Uses a layered rule
|
||||
system configured in `pyproject.toml`. Safe-by-default: any unknown file
|
||||
defaults to user-facing.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.classify_changes [--base <ref>] [--head <ref>] \
|
||||
[--quiet] [--check <category>] [--github-output]
|
||||
```
|
||||
|
||||
### `discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners at repository, organization, and
|
||||
instance levels. Falls back to `MOLECULE_RUNNERS` repo variable or
|
||||
`DEFAULT_MAX_RUNNERS` (3).
|
||||
|
||||
```bash
|
||||
python -m devx.ci.discover_runners --owner <owner> --repo <repo> [--count] [--indices]
|
||||
```
|
||||
|
||||
### `detect_release_commit.py`
|
||||
|
||||
Detects whether the latest git commit is a release commit. Writes
|
||||
`is-release=true|false` to `$GITHUB_OUTPUT`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.detect_release_commit
|
||||
```
|
||||
|
||||
### `push_badges.py`
|
||||
|
||||
Generates SVG badge files, pushes them to the `badges` branch, and updates
|
||||
README.md and docs/index.md with cache-busting `raw/commit/<sha>/` URLs.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.push_badges [--output-dir <dir>] [--branch <branch>] \
|
||||
[--no-readme-update] [--retries <n>]
|
||||
```
|
||||
|
||||
### `distribute_molecule.py`
|
||||
|
||||
Distributes molecule (scenario, platform) pairs across N parallel runners.
|
||||
Discovers scenarios under `ansible/roles/*/molecule/`.
|
||||
|
||||
```bash
|
||||
python -m devx.molecule.distribute_molecule --runner-index <i> --max-runners <n>
|
||||
python -m devx.molecule.distribute_molecule --list
|
||||
python -m devx.molecule.distribute_molecule --list-platforms
|
||||
```
|
||||
|
||||
### `molecule_ci_guard.py`
|
||||
|
||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||
failures. Aborts early if another runner fails the same job.
|
||||
|
||||
```bash
|
||||
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
|
||||
```
|
||||
|
||||
### `validate_commit_msg.py`
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only
|
||||
(no `{PREFIX}-N` prefix). On master: must have `{PREFIX}-N` prefix from
|
||||
auto-merge, followed by a conventional commit message.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.validate_commit_msg <commit_msg_file> [--branch <branch>]
|
||||
```
|
||||
|
||||
### `sync_wiki.py`
|
||||
|
||||
Syncs documentation from `docs/` to the Gitea wiki via the API. Reads
|
||||
`docs/mapping.json` for file-to-page mapping. Supports `--dry-run`,
|
||||
`--verify`, and `--strict` (full integrity check).
|
||||
|
||||
```bash
|
||||
python -m devx.ci.sync_wiki [--dry-run] [--repo <owner/repo>] [--verify] [--strict]
|
||||
```
|
||||
|
||||
### `check_translations.py`
|
||||
|
||||
Validates translation files against the Python source code. Checks for
|
||||
missing keys, dead keys, and missing languages.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.check_translations [--translations <file>]...
|
||||
```
|
||||
|
||||
### `doc_coverage.py`
|
||||
|
||||
Checks documentation coverage for CLI commands and major modules. Parses
|
||||
Click commands from `cli.py` and verifies documentation exists.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.doc_coverage [--docs-dir <dir>] [--fail-on-missing]
|
||||
```
|
||||
|
||||
### `distribute_files.py`
|
||||
|
||||
Distributes files matching a glob pattern across N parallel runners
|
||||
(round-robin). Writes the assigned file list to `$GITHUB_ENV`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.distribute_files --pattern <glob> --runner-index <i> \
|
||||
--max-runners <n> [--github-env] [--skip-if-excess]
|
||||
```
|
||||
|
||||
### `integration_guard.py`
|
||||
|
||||
Runs pytest with cross-runner failure detection. If any other
|
||||
integration-tests matrix runner reports failure, the current pytest
|
||||
subprocess is killed and this runner exits early.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.integration_guard -- <pytest args>
|
||||
```
|
||||
|
||||
## Release process summary
|
||||
|
||||
The complete release process from PR to published package:
|
||||
|
||||
1. **PR merged** — `auto-merge` squash-merges the PR to master with
|
||||
`{PREFIX}-N <conventional commit>` title
|
||||
2. **Post-merge triggers** — the merge push triggers `post-merge.yml`
|
||||
3. **detect-type** — confirms the commit is not a release commit
|
||||
4. **release** — `release.py` calculates the next version, updates files,
|
||||
runs tests, commits `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`,
|
||||
and pushes to master
|
||||
5. **Tag push triggers publish** — the tag push triggers `publish.yml`
|
||||
6. **publish** — `publish.py` builds the package, publishes to the Gitea PyPI
|
||||
registry, and creates a Gitea release with git-cliff notes
|
||||
7. **sync-wiki** — documentation is synced to the Gitea wiki
|
||||
8. **badges** — quality badges are regenerated and pushed to the `badges`
|
||||
branch; README and docs/index.md are updated with cache-busting URLs
|
||||
9. **vikunja** — the corresponding Vikunja task is marked as done
|
||||
10. **configure-repo** — branch protection and labels are ensured
|
||||
|
||||
The release commit's post-merge run skips all jobs except `badges` (which
|
||||
picks up the new version number). This prevents infinite loops.
|
||||
|
||||
## Failure handling
|
||||
|
||||
Every job in the post-merge and publish workflows has a `notify_failure` step
|
||||
that runs `if: failure()`. This creates a Gitea issue with the workflow name,
|
||||
run ID, and commit SHA, ensuring failures that would otherwise go unnoticed
|
||||
in the Actions tab are surfaced as issues. The issue is created via the tea
|
||||
CLI with a `bug` label if available.
|
||||
@@ -0,0 +1,469 @@
|
||||
# CLI Commands
|
||||
|
||||
devx provides a CLI with three command groups: `ci`, `tools`, and `molecule`.
|
||||
Each subcommand delegates to the corresponding Python module via
|
||||
`python -m devx.*`, so `devx ci release` is equivalent to
|
||||
`python -m devx.ci.release`.
|
||||
|
||||
```bash
|
||||
devx --help # show all command groups
|
||||
devx --version # show package version
|
||||
devx ci --help # show CI commands
|
||||
devx tools --help # show tools commands
|
||||
devx molecule --help # show molecule commands
|
||||
```
|
||||
|
||||
## CI Commands
|
||||
|
||||
### `devx ci auto-merge`
|
||||
|
||||
Auto-merge a PR when all CI checks pass. Reads the task ID from the branch
|
||||
name, validates the PR title format against
|
||||
the Vikunja task title, extracts the conventional commit message from PR
|
||||
commits, and squash-merges with `{PREFIX}-N <conventional commit>` title.
|
||||
|
||||
If the head branch is behind master (HTTP 405), automatically pulls master,
|
||||
rebases, force-pushes, and retries the merge.
|
||||
|
||||
```bash
|
||||
devx ci auto-merge <branch> <pr_title> <owner/repo> <pr_number>
|
||||
# Example:
|
||||
devx ci auto-merge DEVX-12-add-feature "DEVX-12: Add feature" oblachno-oss/devx 42
|
||||
```
|
||||
|
||||
### `devx ci check-translations`
|
||||
|
||||
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 `src/devx/**/*.py`.
|
||||
|
||||
Checks performed:
|
||||
- **Missing keys** — a `_()` call in code has no entry in the translations file
|
||||
- **Dead keys** — a key in the translations file is not used in any code
|
||||
- **Missing languages** — a key exists but is missing one of the six
|
||||
supported languages (en, bg, de, ru, zh, pl)
|
||||
|
||||
```bash
|
||||
devx ci check-translations
|
||||
devx ci check-translations --translations path/to/translations.json
|
||||
```
|
||||
|
||||
### `devx ci classify-changes`
|
||||
|
||||
Classify git changes as user-facing or workflow-only. Used to skip releases
|
||||
for infrastructure-only changes. Classification rules are configured in
|
||||
`pyproject.toml` under `[tool.devx.classify]`.
|
||||
|
||||
```bash
|
||||
devx ci classify-changes --base origin/master --head HEAD
|
||||
devx ci classify-changes --base origin/master --head HEAD --github-output
|
||||
devx ci classify-changes --quiet --check user-facing
|
||||
devx ci classify-changes --check ansible # custom tag from pyproject.toml
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--base <ref>` — base ref (default: latest tag)
|
||||
- `--head <ref>` — head ref (default: HEAD)
|
||||
- `--quiet` — only output true/false
|
||||
- `--check <category>` — check specific category: `all` (default),
|
||||
`user-facing`, or any tag name defined in `[tool.devx.classify.tags]`
|
||||
- `--github-output` — write results to `$GITHUB_OUTPUT` for CI workflow steps
|
||||
|
||||
Exit code 2 indicates workflow-only changes (no release needed).
|
||||
|
||||
### `devx ci detect-release-commit`
|
||||
|
||||
Detect whether the latest git commit is a release commit
|
||||
(`release: vX.Y.Z [skip ci]`). Writes `is-release=true` or `is-release=false`
|
||||
to `$GITHUB_OUTPUT` for use in CI workflow conditionals.
|
||||
|
||||
```bash
|
||||
devx ci detect-release-commit
|
||||
```
|
||||
|
||||
### `devx ci discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for dynamic job distribution.
|
||||
Queries the Gitea API for registered runners at repository, organization, and
|
||||
instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
|
||||
`DEFAULT_MAX_RUNNERS` (3).
|
||||
|
||||
```bash
|
||||
devx ci discover-runners --owner oblachno-oss --repo devx
|
||||
devx ci discover-runners --owner oblachno-oss --repo devx --count
|
||||
devx ci discover-runners --owner oblachno-oss --repo devx --indices
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--count` — print the number of available runners
|
||||
- `--indices` — print a JSON array `[0, 1, ..., N-1]` for use as a dynamic
|
||||
matrix in Gitea Actions
|
||||
|
||||
### `devx ci distribute-files`
|
||||
|
||||
Distribute files across parallel runners (round-robin). 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`.
|
||||
|
||||
```bash
|
||||
devx ci distribute-files --pattern "tests/integration/test_*.py" \
|
||||
--runner-index 1 --max-runners 3 --github-env
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--pattern <glob>` — glob pattern for files to distribute
|
||||
- `--runner-index <i>` — current runner index (0-based)
|
||||
- `--max-runners <n>` — total number of runners (default: 3)
|
||||
- `--github-env` — write file list to `$GITHUB_ENV`
|
||||
- `--skip-if-excess` — skip if fewer files than runners
|
||||
|
||||
### `devx ci doc-coverage`
|
||||
|
||||
Check documentation coverage for CLI commands and major modules. Parses
|
||||
Click commands from `cli.py` and checks if each has documentation in
|
||||
`docs/user/cli-commands.md`. Verifies core modules are documented in
|
||||
`architecture.md` and CI scripts in `ci-cd-workflow.md`.
|
||||
|
||||
```bash
|
||||
devx ci doc-coverage
|
||||
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
||||
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
||||
missing
|
||||
|
||||
### `devx ci integration-guard`
|
||||
|
||||
Run pytest with cross-runner failure detection. If any
|
||||
other integration-tests matrix runner reports failure, the current pytest
|
||||
subprocess is killed and this runner exits early with code 1.
|
||||
|
||||
```bash
|
||||
devx ci integration-guard -- test_a.py test_b.py
|
||||
devx ci integration-guard -- -x -v --tb=short test_a.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`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
||||
|
||||
### `devx ci notify-failure`
|
||||
|
||||
Create a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
|
||||
creation with a `bug` label if available.
|
||||
|
||||
```bash
|
||||
devx ci notify-failure --repo oblachno-oss/devx --run-id 123 \
|
||||
--workflow ci --commit abc123def456
|
||||
devx ci notify-failure --repo oblachno-oss/devx --run-id 123 \
|
||||
--workflow post-merge/release --commit abc123def456 --auto-login
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--repo <owner/repo>` — repository (required)
|
||||
- `--run-id <id>` — CI run ID (required)
|
||||
- `--workflow <name>` — workflow name (required)
|
||||
- `--commit <sha>` — commit SHA (required)
|
||||
- `--auto-login` — configure tea CLI login from `REPO_TOKEN` before creating
|
||||
the issue
|
||||
|
||||
### `devx ci post-merge`
|
||||
|
||||
Update Vikunja task after a merge to master. Extracts the task ID from the
|
||||
commit message, marks the task as done, and posts a comment with the merge SHA.
|
||||
|
||||
```bash
|
||||
devx ci post-merge "DEVX-12 feat: add feature" --git-sha abc123def456
|
||||
```
|
||||
|
||||
### `devx ci pr-review`
|
||||
|
||||
Run automated PR review. Fetches the PR diff via the Gitea API and runs a
|
||||
series of checks, posting a structured review (`COMMENT` or
|
||||
`REQUEST_CHANGES`).
|
||||
|
||||
Checks: architecture compliance, best practices, security, i18n, resource
|
||||
management, documentation, test coverage, and commit conventions.
|
||||
|
||||
```bash
|
||||
devx ci pr-review 42 oblachno-oss/devx
|
||||
```
|
||||
|
||||
### `devx ci publish`
|
||||
|
||||
Build package, publish to Gitea PyPI registry (or standard PyPI), and create
|
||||
a Gitea release with git-cliff-generated notes.
|
||||
|
||||
```bash
|
||||
devx ci publish v1.0.0 oblachno-oss/devx
|
||||
devx ci publish v1.0.0 oblachno-oss/devx --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
devx ci publish v1.0.0 oblachno-oss/devx --skip-build # Gitea release only
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--registry-url <url>` — 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).
|
||||
- `--skip-build` — skip package build and PyPI publish (for non-Python repos
|
||||
that only need a Gitea release)
|
||||
|
||||
### `devx ci push-badges`
|
||||
|
||||
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>/` URLs.
|
||||
|
||||
```bash
|
||||
devx ci push-badges
|
||||
devx ci push-badges --output-dir .badges/ --branch master
|
||||
devx ci push-badges --no-readme-update # skip README update (local testing)
|
||||
devx ci push-badges --retries 3 # retry on git push failures
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--output-dir <dir>` — temporary directory for badge files (default:
|
||||
`.badges/`)
|
||||
- `--branch <branch>` — branch to sync before generating badges (default:
|
||||
`master`)
|
||||
- `--no-readme-update` — skip updating README with cache-busting URLs
|
||||
- `--retries <n>` — number of attempts on git push failures (default: 1).
|
||||
Between attempts, fetches latest master and waits 10s.
|
||||
|
||||
### `devx ci release`
|
||||
|
||||
Automated release: calculate next version, update files, tag, and push. Uses
|
||||
git-cliff to determine the next semver version from conventional commits.
|
||||
|
||||
```bash
|
||||
devx ci release
|
||||
devx ci release --dry-run # preview without making changes
|
||||
devx ci release --skip-tests # skip lint and tests (emergency only)
|
||||
devx ci release --verify # check tag/version/changelog alignment
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--dry-run` — show what would happen without making changes
|
||||
- `--skip-tests` — skip lint and test verification (NOT recommended — only
|
||||
for emergency releases)
|
||||
- `--verify` — verify tag/version/changelog alignment and exit (no changes
|
||||
made)
|
||||
|
||||
### `devx ci sync-wiki`
|
||||
|
||||
Sync documentation from `docs/` to the Gitea wiki. Reads `docs/mapping.json`
|
||||
for file-to-page mapping. Pages that exist in the wiki but not in the mapping
|
||||
are left untouched.
|
||||
|
||||
```bash
|
||||
devx ci sync-wiki --repo oblachno-oss/devx
|
||||
devx ci sync-wiki --repo oblachno-oss/devx --dry-run
|
||||
devx ci sync-wiki --repo oblachno-oss/devx --verify
|
||||
devx ci sync-wiki --repo oblachno-oss/devx --strict
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--dry-run` — show what would happen without making changes
|
||||
- `--repo <owner/repo>` — repository (auto-detected if omitted)
|
||||
- `--verify` — after syncing, verify each page has non-empty content. Exit 1
|
||||
if any page is empty or mismatched.
|
||||
- `--strict` — full integrity check: verify page count, missing pages, stale
|
||||
pages, and content. Implies `--verify`.
|
||||
|
||||
### `devx ci validate-commit-msg`
|
||||
|
||||
Validate commit messages for conventional commit format. On feature branches:
|
||||
conventional commits only (no `{PREFIX}-N` prefix). On master: must have
|
||||
`{PREFIX}-N` prefix from auto-merge, followed by a conventional commit
|
||||
message.
|
||||
|
||||
```bash
|
||||
devx ci validate-commit-msg commit-msg.txt
|
||||
devx ci validate-commit-msg commit-msg.txt --branch master
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--branch <branch>` — override branch detection (for CI use)
|
||||
|
||||
## Tools Commands
|
||||
|
||||
### `devx tools check-test-speed`
|
||||
|
||||
Run unit tests and enforce execution-time budgets. Two quality gates:
|
||||
|
||||
- **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)
|
||||
|
||||
Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits
|
||||
per-test timing lines.
|
||||
|
||||
```bash
|
||||
devx tools check-test-speed
|
||||
devx tools check-test-speed --max-seconds 10
|
||||
devx tools check-test-speed --max-seconds 4 --max-single-seconds 0.5
|
||||
```
|
||||
|
||||
### `devx tools configure-repo`
|
||||
|
||||
Configure repository: branch protection and labels via the Gitea REST API.
|
||||
Sets up master branch protection (required status checks, block on rejected
|
||||
reviews, block on outdated branch) and creates standard labels.
|
||||
|
||||
```bash
|
||||
devx tools configure-repo --repo devx --owner oblachno-oss
|
||||
```
|
||||
|
||||
Status check contexts are read from `DEVX_STATUS_CHECKS` (comma-separated) or
|
||||
default to `CI / quality (pull_request)`.
|
||||
|
||||
### `devx tools generate-badges`
|
||||
|
||||
Generate self-contained SVG badge files from project metrics. Runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files that can be served as static files from the Gitea raw file API.
|
||||
|
||||
Badges generated: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
||||
`version.svg`, `python.svg`.
|
||||
|
||||
```bash
|
||||
devx tools generate-badges
|
||||
devx tools generate-badges --output-dir .badges/
|
||||
```
|
||||
|
||||
### `devx tools generate-cliff-config`
|
||||
|
||||
Generate a `cliff.toml` configuration file with the correct task ID prefix
|
||||
preprocessor. Eliminates the need to manually duplicate and maintain
|
||||
`cliff.toml` across repos that use devx.
|
||||
|
||||
```bash
|
||||
devx tools generate-cliff-config --prefix GRM
|
||||
devx tools generate-cliff-config --prefix GRM --output cliff.toml
|
||||
devx tools generate-cliff-config --prefix GRM --force # overwrite existing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
|
||||
or `DEVX`)
|
||||
- `--output <file>` — output file path (default: `cliff.toml`)
|
||||
- `--force` — overwrite existing file
|
||||
|
||||
### `devx tools install-checkmake`
|
||||
|
||||
Install checkmake (Makefile linter) if not already present. Tries
|
||||
`go install` first if Go is available, otherwise downloads the latest
|
||||
pre-built Linux binary from the official GitHub releases.
|
||||
|
||||
```bash
|
||||
devx tools install-checkmake
|
||||
```
|
||||
|
||||
### `devx tools install-tools`
|
||||
|
||||
Install CI/CD development tools that are not Python packages: actionlint,
|
||||
git-cliff, act_runner, and tea. Each tool is installed to `~/.local/bin` if
|
||||
not already on PATH. Idempotent: skips tools that are already available.
|
||||
|
||||
```bash
|
||||
devx tools install-tools # install all
|
||||
devx tools install-tools --tool actionlint # install one
|
||||
devx tools install-tools --tool git-cliff --tool tea # install specific
|
||||
devx tools install-tools --list # list status
|
||||
```
|
||||
|
||||
### `devx tools setup`
|
||||
|
||||
Project setup: install Python dependencies (editable mode with extras),
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
|
||||
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
|
||||
profile from `.env`.
|
||||
|
||||
```bash
|
||||
devx tools setup --bin .venv/bin
|
||||
devx tools setup --bin .venv/bin --extras "ci,lint"
|
||||
devx tools setup --bin .venv/bin --no-pre-commit --no-tea-login
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--bin <dir>` — virtualenv bin directory (required)
|
||||
- `--extras <groups>` — pip extras to install (default: `dev`)
|
||||
- `--no-pre-commit` — skip pre-commit hook installation
|
||||
- `--no-tea-login` — skip tea CLI login configuration
|
||||
|
||||
## Molecule Commands
|
||||
|
||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||
|
||||
### `devx molecule all`
|
||||
|
||||
Run all molecule scenarios on all supported OS platforms. Sequential
|
||||
execution — CI uses the parallel matrix instead.
|
||||
|
||||
```bash
|
||||
devx molecule all
|
||||
devx molecule all --bin .venv/bin
|
||||
```
|
||||
|
||||
### `devx molecule discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for molecule tests. Same logic as
|
||||
`devx ci discover-runners` but intended for molecule-specific workflows.
|
||||
|
||||
```bash
|
||||
devx molecule discover-runners --owner oblachno-oss --repo devx --indices
|
||||
```
|
||||
|
||||
### `devx molecule distribute`
|
||||
|
||||
Distribute molecule (scenario, platform) pairs across N parallel runners.
|
||||
Discovers scenarios under `ansible/roles/*/molecule/` and crosses them with
|
||||
the supported OS platform matrix.
|
||||
|
||||
```bash
|
||||
devx molecule distribute --runner-index 1 --max-runners 3
|
||||
devx molecule distribute --list # list all scenarios
|
||||
devx molecule distribute --list-platforms # list platforms
|
||||
devx molecule distribute --roles-root ansible/roles # multi-role repos
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--runner-index <i>` — current runner index (0-based)
|
||||
- `--max-runners <n>` — total number of runners (default: 3)
|
||||
- `--list` — list all scenarios, one per line
|
||||
- `--list-platforms` — list all platforms, one per line
|
||||
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
||||
`ansible/roles`)
|
||||
|
||||
### `devx molecule guard`
|
||||
|
||||
Run molecule tests sequentially with CI failure polling. 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.
|
||||
|
||||
```bash
|
||||
devx molecule guard pair1 pair2 pair3
|
||||
devx molecule guard --roles-root ansible/roles pair1 pair2
|
||||
```
|
||||
|
||||
Each pair is encoded as:
|
||||
- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command`
|
||||
- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command`
|
||||
|
||||
Options:
|
||||
- `--roles-root <dir>` — roles root directory for multi-role repos
|
||||
|
||||
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`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
[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",
|
||||
"build>=1.5.0",
|
||||
"twine>=6.2.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", "make/*.mak"]
|
||||
|
||||
[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)
|
||||
# Project-specific devx configuration (read by devx.config)
|
||||
[tool.devx]
|
||||
task_prefix = "DEVX"
|
||||
vikunja_project_id = 8
|
||||
repo_owner = "oblachno-oss"
|
||||
repo_name = "devx"
|
||||
|
||||
# 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
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.16.0"
|
||||
@@ -0,0 +1,413 @@
|
||||
"""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 create_pr(self, title: str, head: str, base: str = "master", body: str = "") -> dict[str, Any]:
|
||||
"""Create a pull request and return the PR dict.
|
||||
|
||||
Args:
|
||||
title: PR title.
|
||||
head: Head branch name.
|
||||
base: Base branch name (default: master).
|
||||
body: PR description (markdown).
|
||||
"""
|
||||
payload: dict[str, Any] = {"title": title, "head": head, "base": base}
|
||||
if body:
|
||||
payload["body"] = body
|
||||
r = self._request("POST", "/pulls", json=payload)
|
||||
return r.json()
|
||||
|
||||
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
|
||||
"""List pull requests, optionally filtered by state.
|
||||
|
||||
Args:
|
||||
state: ``open``, ``closed``, ``all`` (default).
|
||||
**params: Additional query params (e.g. ``q="keyword"`` for title search).
|
||||
"""
|
||||
params.setdefault("state", state)
|
||||
r = self._request("GET", "/pulls", params=params)
|
||||
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 create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
|
||||
"""Create a task in a project and return the created task dict.
|
||||
|
||||
Args:
|
||||
project_id: Target Vikunja project ID.
|
||||
title: Task title (required, non-empty).
|
||||
description: Task description (HTML supported, optional).
|
||||
"""
|
||||
r = self._request(
|
||||
"PUT",
|
||||
f"/projects/{project_id}/tasks",
|
||||
json={"title": title, "description": description},
|
||||
)
|
||||
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 list_comments(self, task_id: int) -> list[dict[str, Any]]:
|
||||
"""List all comments on a task."""
|
||||
r = self._request("GET", f"/tasks/{task_id}/comments")
|
||||
return r.json()
|
||||
|
||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||
"""Update task fields via POST (full replacement semantics).
|
||||
|
||||
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
|
||||
Unspecified fields are reset to their type defaults. Use
|
||||
``update_task_safe`` to preserve existing fields.
|
||||
"""
|
||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||
|
||||
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
|
||||
"""Safely update task fields using read-merge-write pattern.
|
||||
|
||||
Fetches the full task body, merges the provided fields on top,
|
||||
and POSTs the complete body back. This prevents accidental
|
||||
resets of done status, title, etc.
|
||||
"""
|
||||
task = self.get_task(task_id)
|
||||
task.update(fields)
|
||||
r = self._request("POST", f"/tasks/{task_id}", json=task)
|
||||
return r.json()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/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 the branch name
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``), validates the PR title against
|
||||
the Vikunja task, 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" # Deprecated, kept for backward-compat warnings
|
||||
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 branch name.
|
||||
|
||||
The branch name is the sole source of truth for the task ID
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). Branches must include
|
||||
the task ID prefix — there is no ``.taskid`` file fallback.
|
||||
|
||||
If a stale ``.taskid`` file exists and disagrees with the branch
|
||||
name, a deprecation warning is printed advising its removal.
|
||||
"""
|
||||
branch_task_id = extract_task_id(branch)
|
||||
if branch_task_id:
|
||||
# Warn about stale .taskid file if it exists and disagrees
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
file_task_id = path.read_text(encoding="utf-8").strip()
|
||||
if file_task_id and file_task_id != branch_task_id:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). "
|
||||
"Delete .taskid from the repo — branch name is the sole source of truth.",
|
||||
file_id=file_task_id,
|
||||
branch_id=branch_task_id,
|
||||
)
|
||||
)
|
||||
return branch_task_id
|
||||
return ""
|
||||
|
||||
|
||||
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 branch name '{branch}'. "
|
||||
"Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).",
|
||||
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 — do NOT auto-rebase.
|
||||
# Auto-rebasing creates a feedback loop: the force-push triggers
|
||||
# a new pull_request synchronize event, which starts a new CI run,
|
||||
# which runs auto-merge again, which rebases again, etc.
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Branch is behind master. Rebase manually:\n"
|
||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
||||
"Then re-add the ready-to-merge label.",
|
||||
)
|
||||
) 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()
|
||||
@@ -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 6 supported
|
||||
languages (en, bg, de, ru, zh, pl). 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", "pl")
|
||||
|
||||
# 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)
|
||||
# Dynamic keys are common status strings used via _(variable) that
|
||||
# can't be detected by AST scanning. Include them for all projects.
|
||||
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()
|
||||
@@ -0,0 +1,740 @@
|
||||
#!/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",
|
||||
]
|
||||
|
||||
|
||||
@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.",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force user-facing-changed=true regardless of actual changes. "
|
||||
"Used by workflow_dispatch with force-deploy input.",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
|
||||
"""Classify git changes and output results."""
|
||||
classifier = _get_classifier()
|
||||
available_tags = list(classifier.config.tags.keys())
|
||||
|
||||
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
|
||||
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
|
||||
force = True
|
||||
|
||||
if force and github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||
return
|
||||
|
||||
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()
|
||||
@@ -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``.
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/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
|
||||
if "\n" in value:
|
||||
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
|
||||
delimiter = "EOF"
|
||||
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/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.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.integration_guard \\
|
||||
-- test_file1.py test_file2.py
|
||||
|
||||
# With pytest options
|
||||
python3 -m devx.ci.integration_guard \\
|
||||
-- -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)
|
||||
def cli(pytest_args: tuple[str, ...]) -> 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"]
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/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
|
||||
from pathlib import Path
|
||||
|
||||
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."""
|
||||
# Clean dist/ to avoid uploading stale packages from previous builds
|
||||
# (Gitea PyPI returns 409 Conflict for already-published versions).
|
||||
dist_dir = Path("dist")
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
|
||||
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:
|
||||
# Twine writes errors to stdout (not stderr), so check both.
|
||||
combined = f"{result.stdout}\n{result.stderr}".strip()
|
||||
# 409 Conflict means the package version is already published —
|
||||
# this is not an error, just a sign we're re-running publish.
|
||||
if "409" in combined or "Conflict" in combined:
|
||||
click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag=""))
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
stderr=combined,
|
||||
)
|
||||
)
|
||||
else:
|
||||
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"
|
||||
|
||||
|
||||
def get_latest_tag() -> str | None:
|
||||
"""Get the latest git tag, or None if no tags exist."""
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def is_release_commit(tag: str) -> bool:
|
||||
"""Check if HEAD commit message starts with 'release: <tag>'."""
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "log", "-1", "--format=%s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip().startswith(f"release: {tag}")
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("tag", required=False)
|
||||
@click.argument("repo", required=False)
|
||||
@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).",
|
||||
)
|
||||
@click.option(
|
||||
"--from-tag",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Auto-detect latest tag and check if HEAD is a release commit. "
|
||||
"Skips publish if no tag or HEAD is not a release commit for that tag.",
|
||||
)
|
||||
def main(
|
||||
tag: str | None,
|
||||
repo: str | None,
|
||||
registry_url: str | None,
|
||||
skip_build: bool,
|
||||
from_tag: bool,
|
||||
) -> None:
|
||||
if repo is None:
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if not repo:
|
||||
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
|
||||
if from_tag:
|
||||
detected_tag = get_latest_tag()
|
||||
if not detected_tag:
|
||||
click.echo(_("No tag found — skipping publish."))
|
||||
return
|
||||
if not is_release_commit(detected_tag):
|
||||
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
|
||||
return
|
||||
tag = detected_tag
|
||||
click.echo(_("Publishing release {tag}...", tag=tag))
|
||||
|
||||
if not tag:
|
||||
raise click.ClickException(_("Tag is required (or use --from-tag)."))
|
||||
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()
|
||||
|
||||
try:
|
||||
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."
|
||||
)
|
||||
)
|
||||
except click.ClickException as e:
|
||||
click.echo(
|
||||
_(
|
||||
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
|
||||
error=str(e),
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
# Check if release already exists (idempotent — avoids failure when
|
||||
# called multiple times, e.g. by both post-merge and publish workflows)
|
||||
try:
|
||||
releases = tea.list_releases(repo)
|
||||
if any(r.get("tag_name") == tag for r in releases):
|
||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||
return
|
||||
except TeaCLIError:
|
||||
pass # If listing fails, proceed to create
|
||||
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
if "already" in str(e).lower() and "release" in str(e).lower():
|
||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||
return
|
||||
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()
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/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 os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
path = Path(workspace)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
# 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()
|
||||
@@ -0,0 +1,728 @@
|
||||
#!/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()
|
||||
# Filter to version tags (vX.Y.Z) and sort oldest first
|
||||
version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+$", t)]
|
||||
sorted_tags = sorted(version_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:
|
||||
# Skip non-version tags (e.g., branch names like "master")
|
||||
if not re.match(r"^v\d+\.\d+\.\d+$", tag):
|
||||
continue
|
||||
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}"])
|
||||
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", f"refs/tags/{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", f"refs/tags/{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() if re.match(r"^v\d+\.\d+\.\d+$", t)}
|
||||
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()
|
||||
# Some git versions return "heads/master" instead of "master"
|
||||
branch = branch.removeprefix("heads/")
|
||||
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)
|
||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/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 sys
|
||||
|
||||
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 get_latest_commit_msg() -> str:
|
||||
"""Get the latest commit message from git."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "log", "-1", "--format=%B"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
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", required=False)
|
||||
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
||||
@click.option(
|
||||
"--git",
|
||||
"from_git",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Read commit message from git log instead of a file.",
|
||||
)
|
||||
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
|
||||
if from_git:
|
||||
msg = get_latest_commit_msg()
|
||||
elif commit_msg_file:
|
||||
if commit_msg_file == "-":
|
||||
msg = sys.stdin.read().strip()
|
||||
else:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
else:
|
||||
raise click.ClickException(_("Provide a commit message file or use --git."))
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,256 @@
|
||||
"""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("integration-guard")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_integration_guard(args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
_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()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Shared configuration constants for devx scripts and API clients.
|
||||
|
||||
Configuration is read from two sources, in priority order:
|
||||
|
||||
1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for
|
||||
CI secrets and per-run overrides.
|
||||
2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults,
|
||||
read from the current working directory.
|
||||
|
||||
If neither source provides a value, built-in defaults are used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_pyproject_devx() -> dict[str, object]:
|
||||
"""Load the ``[tool.devx]`` section from pyproject.toml in the CWD.
|
||||
|
||||
Returns an empty dict if the file or section is missing.
|
||||
"""
|
||||
path = Path("pyproject.toml")
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data: dict[str, object] = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, OSError):
|
||||
return {}
|
||||
tool_raw: object = data.get("tool", {})
|
||||
if not isinstance(tool_raw, dict):
|
||||
return {}
|
||||
tool: dict[str, object] = tool_raw # type: ignore[assignment]
|
||||
devx_raw: object = tool.get("devx", {})
|
||||
if not isinstance(devx_raw, dict):
|
||||
return {}
|
||||
devx: dict[str, object] = devx_raw # type: ignore[assignment]
|
||||
return devx
|
||||
|
||||
|
||||
_PYPROJECT = _load_pyproject_devx()
|
||||
|
||||
|
||||
def _get(key: str, env_var: str, default: str) -> str:
|
||||
"""Get a config value: env var > pyproject.toml > default."""
|
||||
env_val = os.getenv(env_var)
|
||||
if env_val is not None:
|
||||
return env_val
|
||||
pyproject_val = _PYPROJECT.get(key)
|
||||
if isinstance(pyproject_val, str):
|
||||
return pyproject_val
|
||||
return default
|
||||
|
||||
|
||||
def _get_int(key: str, env_var: str, default: int) -> int:
|
||||
"""Get an int config value: env var > pyproject.toml > default."""
|
||||
env_val = os.getenv(env_var)
|
||||
if env_val is not None:
|
||||
return int(env_val)
|
||||
pyproject_val = _PYPROJECT.get(key)
|
||||
if isinstance(pyproject_val, int):
|
||||
return pyproject_val
|
||||
return default
|
||||
|
||||
|
||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
||||
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = _get("task_prefix", "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 = _get_int("vikunja_project_id", "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)(\(.+\))?: .+")
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/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"])
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||
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)
|
||||
@@ -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, pl.
|
||||
|
||||
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", "pl"):
|
||||
lang = "en"
|
||||
template = TRANSLATIONS.get(key, {}).get(lang, key)
|
||||
return template.format(**kwargs)
|
||||
@@ -0,0 +1,52 @@
|
||||
# devx.mak — Shared Makefile fragment for devx-integrated projects.
|
||||
#
|
||||
# This fragment provides common targets for Vikunja task management,
|
||||
# PR creation, and pushing. It is designed to be included from a
|
||||
# project's Makefile.
|
||||
#
|
||||
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
|
||||
# is read from [tool.devx] in pyproject.toml by devx.config — no
|
||||
# Makefile variables needed.
|
||||
#
|
||||
# Usage in your Makefile:
|
||||
#
|
||||
# # Set DEVX_PYTHON if you need a specific interpreter
|
||||
# DEVX_PYTHON := $(BIN)/python
|
||||
#
|
||||
# # Include the devx fragment (silent if devx not installed yet)
|
||||
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
|
||||
# "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
# 2>/dev/null)
|
||||
# -include $(DEVX_MAK)
|
||||
#
|
||||
# If devx is not installed, the -include silently skips and the targets
|
||||
# are simply unavailable (run 'make setup' first).
|
||||
#
|
||||
# Variables:
|
||||
# DEVX_PYTHON — Python executable (default: python3)
|
||||
# DEVX_PR_BASE — PR base branch (default: master)
|
||||
|
||||
DEVX_PYTHON ?= python3
|
||||
DEVX_PR_BASE ?= master
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
|
||||
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
|
||||
devx-create-task:
|
||||
@$(DEVX_PYTHON) -m devx.tools.create_task
|
||||
|
||||
# Create a PR with title auto-derived from the Vikunja task
|
||||
# (owner/repo read from [tool.devx] in pyproject.toml)
|
||||
devx-create-pr:
|
||||
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
|
||||
|
||||
# Push current branch to origin
|
||||
devx-push:
|
||||
@git push -u origin HEAD
|
||||
|
||||
# Validate devx configuration in pyproject.toml
|
||||
devx-check-config:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_config
|
||||
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/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.
|
||||
|
||||
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 ...
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from 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"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pairs", nargs=-1, required=True)
|
||||
@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, ...], 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."))
|
||||
|
||||
# When devx is installed as a pip package, __file__ resolves to the
|
||||
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
|
||||
# (set by Gitea Actions) or cwd as the repo root.
|
||||
repo_root = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
|
||||
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
|
||||
if gitea_url and token and run_id:
|
||||
poller = threading.Thread(
|
||||
target=poll_for_other_failures,
|
||||
args=(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
run_id,
|
||||
job_name,
|
||||
current_index,
|
||||
stop_event,
|
||||
failed_event,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
poller.start()
|
||||
|
||||
try:
|
||||
for pair in pairs:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
|
||||
if rc != 0:
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
# Prune Docker data between scenarios to prevent disk exhaustion
|
||||
# in Docker-in-Docker molecule containers (each scenario pulls
|
||||
# hundreds of MB of images that accumulate across pairs).
|
||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||
subprocess.run( # nosec B603, B607
|
||||
["docker", "system", "prune", "-af", "--volumes"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -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
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure Docker is available for molecule tests in CI.
|
||||
|
||||
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
|
||||
Docker socket mounted. This module verifies Docker is accessible and
|
||||
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
|
||||
connects to the same socket as the Docker CLI.
|
||||
|
||||
If the host socket is not available, it tries the rootless socket, then
|
||||
starts a local ``dockerd`` with the vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.molecule.start_docker [--timeout 30]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DOCKER_SOCK = "/var/run/docker.sock"
|
||||
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
|
||||
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
|
||||
|
||||
|
||||
def is_docker_ready() -> bool:
|
||||
"""Check if Docker daemon is responding on the configured socket."""
|
||||
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_HOST": docker_host},
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _diagnose_socket() -> None:
|
||||
"""Print diagnostic info about the Docker socket."""
|
||||
click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}")
|
||||
click.echo(f"Socket path: {DOCKER_SOCK}")
|
||||
click.echo(f"Socket exists: {os.path.exists(DOCKER_SOCK)}")
|
||||
if os.path.exists(DOCKER_SOCK):
|
||||
stat = os.stat(DOCKER_SOCK)
|
||||
click.echo(f"Socket mode: {oct(stat.st_mode)}")
|
||||
click.echo(f"Socket uid: {stat.st_uid}, gid: {stat.st_gid}")
|
||||
# Check if it's a mount point
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["mount"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
docker_mounts = [line for line in result.stdout.splitlines() if "docker" in line.lower()]
|
||||
if docker_mounts:
|
||||
click.echo("Docker-related mounts:")
|
||||
for line in docker_mounts:
|
||||
click.echo(f" {line}")
|
||||
else:
|
||||
click.echo("No Docker-related mounts found")
|
||||
# Check docker context
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "context", "ls"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
click.echo(f"Docker contexts:\n{result.stdout}")
|
||||
# Try docker info without DOCKER_HOST
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
click.echo(f"docker info (no DOCKER_HOST): rc={result.returncode}")
|
||||
if result.returncode != 0:
|
||||
click.echo(f" stderr: {result.stderr[:500]}")
|
||||
else:
|
||||
# Print server version and storage driver
|
||||
for line in result.stdout.splitlines():
|
||||
if "Server Version" in line or "Storage Driver" in line or "Docker Root Dir" in line:
|
||||
click.echo(f" {line.strip()}")
|
||||
|
||||
|
||||
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"""Ensure Docker is ready for molecule tests.
|
||||
|
||||
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
|
||||
returns immediately. If not, tries the rootless socket. If neither
|
||||
works, starts a local ``dockerd`` with vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Returns ``True`` if Docker is ready, ``False`` if it failed to
|
||||
start within the timeout.
|
||||
"""
|
||||
# Point Docker CLI and Python library to the socket explicitly
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# Diagnose socket state
|
||||
click.echo("--- Docker socket diagnostics ---")
|
||||
_diagnose_socket()
|
||||
click.echo("--- End diagnostics ---")
|
||||
|
||||
# Check if host Docker is already available
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Try rootless socket (e.g. /run/user/994/docker.sock)
|
||||
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
|
||||
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Scan for any rootless sockets at other UIDs
|
||||
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
|
||||
if sock == ROOTLESS_SOCK:
|
||||
continue
|
||||
click.echo(f"Trying alternative rootless socket: {sock}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{sock}"
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
click.echo(_("Host Docker not available, starting local dockerd..."))
|
||||
|
||||
# Reset DOCKER_HOST to host socket for local dockerd
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# Start local dockerd (requires privileged container)
|
||||
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
||||
mode="w", suffix="dockerd.log", delete=False
|
||||
)
|
||||
click.echo(f"dockerd log: {log_file.name}")
|
||||
subprocess.Popen( # nosec B603 B607
|
||||
[
|
||||
"dockerd",
|
||||
"--storage-driver",
|
||||
"vfs",
|
||||
"-H",
|
||||
f"unix://{DOCKER_SOCK}",
|
||||
],
|
||||
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)
|
||||
|
||||
# Print dockerd log on failure
|
||||
click.echo(_("Docker daemon failed to start"))
|
||||
click.echo("--- dockerd log ---")
|
||||
try:
|
||||
with open(log_file.name) as f:
|
||||
log_content = f.read()
|
||||
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
|
||||
except OSError as e:
|
||||
click.echo(f"Could not read log: {e}")
|
||||
click.echo("--- End dockerd log ---")
|
||||
|
||||
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):
|
||||
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if github_env and os.environ.get("DOCKER_HOST"):
|
||||
with open(github_env, "a") as f:
|
||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -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, ""))
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate devx configuration consistency in pyproject.toml.
|
||||
|
||||
Checks:
|
||||
1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name)
|
||||
2. devx version is consistent across all extras that mention it
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
@click.command()
|
||||
def cli() -> None:
|
||||
"""Validate devx configuration in pyproject.toml."""
|
||||
path = Path("pyproject.toml")
|
||||
if not path.exists():
|
||||
click.echo(_("pyproject.toml not found in current directory."))
|
||||
sys.exit(1)
|
||||
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data = tomllib.load(f)
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
# Check [tool.devx] section
|
||||
devx_cfg = data.get("tool", {}).get("devx", {})
|
||||
required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"}
|
||||
missing = required_keys - set(devx_cfg.keys())
|
||||
if missing:
|
||||
errors.append(
|
||||
_("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))),
|
||||
)
|
||||
|
||||
# Check devx version consistency across extras
|
||||
optional_deps = data.get("project", {}).get("optional-dependencies", {})
|
||||
devx_versions: dict[str, str] = {}
|
||||
for extra_name, deps in optional_deps.items():
|
||||
for dep in deps:
|
||||
# Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc.
|
||||
m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep)
|
||||
if m:
|
||||
devx_versions[extra_name] = m.group(2)
|
||||
|
||||
if devx_versions:
|
||||
unique_versions = set(devx_versions.values())
|
||||
if len(unique_versions) > 1:
|
||||
detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items()))
|
||||
errors.append(
|
||||
_("devx version mismatch across extras: {detail}", detail=detail),
|
||||
)
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
click.echo(f"ERROR: {err}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -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
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/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 DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
|
||||
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
|
||||
if "/" in repo and owner is None:
|
||||
parts = repo.split("/", 1)
|
||||
owner, repo = parts[0], parts[1]
|
||||
click.echo(
|
||||
_(
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a pull request with the correct title from the Vikunja task.
|
||||
|
||||
This tool is run **after** pushing a feature branch. It:
|
||||
|
||||
1. Extracts the task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``).
|
||||
2. Fetches the Vikunja task title for that task ID.
|
||||
3. Creates a PR with title ``{TASK_PREFIX}-N: <vikunja task title>``.
|
||||
|
||||
This eliminates manual PR title entry and ensures the title always
|
||||
matches the Vikunja task — which is what the auto-merge workflow
|
||||
validates.
|
||||
|
||||
If a PR already exists for the branch, the tool prints its URL and
|
||||
exits successfully (idempotent).
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.create_pr --branch DEVX-31-fix-foo
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_repo_name() -> str:
|
||||
"""Auto-detect repository name from env vars or git remote."""
|
||||
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if name:
|
||||
return name
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if github_repo and "/" in github_repo:
|
||||
return github_repo.split("/", 1)[1]
|
||||
raise click.ClickException(
|
||||
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
|
||||
)
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""Fetch the Vikunja task title for the given task 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. Required to derive PR title."))
|
||||
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}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
|
||||
"""Return an existing open PR for the branch, or None."""
|
||||
prs = client.list_prs(state="open")
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def create_pr(
|
||||
branch: str,
|
||||
base: str,
|
||||
body: str,
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
) -> dict:
|
||||
"""Create a PR with the title derived from the Vikunja task.
|
||||
|
||||
Returns the PR dict from the Gitea API.
|
||||
"""
|
||||
task_id = extract_task_id(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
|
||||
branch=branch,
|
||||
prefix=TASK_PREFIX,
|
||||
),
|
||||
)
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR."))
|
||||
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
pr_title = f"{task_id}: {vikunja_title}"
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
existing = find_existing_pr(client, branch)
|
||||
if existing:
|
||||
click.echo(
|
||||
_(
|
||||
"PR already exists: #{index} — {url}",
|
||||
index=existing.get("number", "?"),
|
||||
url=existing.get("html_url", ""),
|
||||
),
|
||||
)
|
||||
return existing
|
||||
|
||||
pr = client.create_pr(title=pr_title, head=branch, base=base, body=body)
|
||||
click.echo(
|
||||
_(
|
||||
"Created PR #{index}: {title}\n {url}",
|
||||
index=pr.get("number", "?"),
|
||||
title=pr_title,
|
||||
url=pr.get("html_url", ""),
|
||||
),
|
||||
)
|
||||
return pr
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--branch", default=None, help="Head branch (default: auto-detect from git).")
|
||||
@click.option("--base", default="master", show_default=True, help="Base branch.")
|
||||
@click.option("--body", default="", help="PR body (markdown). Read from stdin if '-' is passed.")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(branch: str | None, base: str, body: str, owner: str | None, repo: str | None) -> None:
|
||||
"""Create a PR with the correct title from the Vikunja task."""
|
||||
if branch is None:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_("Could not detect current branch: {error}", error=result.stderr.strip()),
|
||||
)
|
||||
branch = result.stdout.strip()
|
||||
|
||||
if body == "-":
|
||||
body = click.get_text_stream("stdin").read().strip()
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
create_pr(branch, base, body, repo_owner, repo_name)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Vikunja task with a detailed HTML description.
|
||||
|
||||
This tool is used during the planning phase of the development workflow
|
||||
to create a well-described task before any code is written. The task
|
||||
identifier (e.g. ``DEVX-N``, ``GRM-N``, ``OBL-INFRA-N``) is then used
|
||||
to name the feature branch and the pull request.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.create_task --title "Add release automation" \\
|
||||
--description "<h2>Overview</h2><p>Implement automated...</p>"
|
||||
|
||||
The project ID and task prefix are read from ``DEVX_VIKUNJA_PROJECT_ID``
|
||||
and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--title", required=True, help="Task title (becomes the Vikunja task title).")
|
||||
@click.option(
|
||||
"--description",
|
||||
default="",
|
||||
help="Task description (HTML supported). Read from stdin if '-' is passed.",
|
||||
)
|
||||
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
|
||||
def cli(title: str, description: str, project_id: int | None) -> None:
|
||||
"""Create a Vikunja task and print its identifier."""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment."))
|
||||
|
||||
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
|
||||
|
||||
if description == "-":
|
||||
description = click.get_text_stream("stdin").read().strip()
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
task = client.create_task(pid, title, description)
|
||||
|
||||
identifier = task.get("identifier", "")
|
||||
task_id = task.get("id", "")
|
||||
click.echo(
|
||||
_(
|
||||
"Created Vikunja task: {identifier} (id={task_id})",
|
||||
identifier=identifier,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
if identifier:
|
||||
click.echo(
|
||||
_(
|
||||
"Next steps:\n"
|
||||
" 1. git checkout master && git pull\n"
|
||||
" 2. git checkout -b {prefix}-{num}-short-description\n"
|
||||
" 3. Implement changes, commit with conventional commit format\n"
|
||||
" 4. git push -u origin HEAD\n"
|
||||
" 5. make create-pr (creates PR with title: {identifier}: {title})",
|
||||
prefix=TASK_PREFIX,
|
||||
num=identifier.split("-")[-1] if "-" in identifier else "N",
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,442 @@
|
||||
#!/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.
|
||||
|
||||
The repo root is resolved from ``GITHUB_WORKSPACE`` or ``os.getcwd()``,
|
||||
so this module works correctly both when run from a source checkout
|
||||
and when devx is installed as a pip package in CI.
|
||||
|
||||
The package name and coverage target are auto-detected from the
|
||||
``src/`` directory structure, making this module reusable across
|
||||
all oblachno-oss repos without per-repo configuration.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.generate_badges --output-dir .badges/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
||||
_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 resolve_repo_root() -> Path:
|
||||
"""Resolve the repository root directory.
|
||||
|
||||
Uses ``GITHUB_WORKSPACE`` env var (set by Gitea Actions) or
|
||||
falls back to ``os.getcwd()``. This ensures the correct repo
|
||||
root is used even when devx is installed as a pip package.
|
||||
"""
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
path = Path(workspace)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def detect_package_name(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the Python package name from ``src/`` directory.
|
||||
|
||||
Looks for the first subdirectory under ``src/`` that contains
|
||||
an ``__init__.py`` file with ``__version__``.
|
||||
|
||||
Returns the package directory name (e.g., ``devx``,
|
||||
``gitea_runner_manager``) or ``None`` if no package is found.
|
||||
"""
|
||||
src_dir = repo_root / "src"
|
||||
if not src_dir.is_dir():
|
||||
return None
|
||||
for entry in sorted(src_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
init_file = entry / "__init__.py"
|
||||
if init_file.exists():
|
||||
return entry.name
|
||||
return None
|
||||
|
||||
|
||||
def detect_coverage_target(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the pytest-cov target from pyproject.toml.
|
||||
|
||||
Parses ``addopts`` in ``[tool.pytest.ini_options]`` for
|
||||
``--cov=src/<package>``. Falls back to ``src/<package>`` if
|
||||
the package is detected but no explicit cov target is found.
|
||||
"""
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
match = re.search(r"--cov=(\S+)", content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Fallback: derive from package name
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg:
|
||||
return f"src/{pkg}"
|
||||
return None
|
||||
|
||||
|
||||
def _xml_escape(text: str) -> str:
|
||||
"""Escape XML special characters."""
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
root = str(cwd or resolve_repo_root())
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=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(repo_root: Path) -> str:
|
||||
"""Read __version__ from the package __init__.py under src/.
|
||||
|
||||
Auto-detects the package directory and reads ``__version__``
|
||||
from its ``__init__.py``.
|
||||
"""
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg is None:
|
||||
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||
if not init_file.exists():
|
||||
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
|
||||
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 detect_testpaths(repo_root: Path) -> list[str]:
|
||||
"""Detect test paths from pyproject.toml or filesystem.
|
||||
|
||||
Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
|
||||
pyproject.toml. Falls back to ``["tests"]`` if the tests/
|
||||
directory exists. Returns an empty list if no test paths
|
||||
are found (pytest will use its own defaults).
|
||||
"""
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
# Match: testpaths = ["dir1", "dir2"]
|
||||
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
|
||||
if match:
|
||||
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
|
||||
resolved = []
|
||||
for p in paths:
|
||||
p = p.strip()
|
||||
if (repo_root / p).exists():
|
||||
resolved.append(p)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
# Fallback: tests/ directory
|
||||
tests_dir = repo_root / "tests"
|
||||
if tests_dir.is_dir():
|
||||
return ["tests"]
|
||||
return []
|
||||
|
||||
|
||||
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
|
||||
"""Run pytest-cov and collect coverage + test count badges.
|
||||
|
||||
Returns (coverage_badge, tests_badge). If pytest is not
|
||||
available or no tests are found, returns 'unknown' badges
|
||||
with a clear warning explaining the failure.
|
||||
"""
|
||||
cov_target = detect_coverage_target(repo_root)
|
||||
if cov_target is None:
|
||||
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
||||
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
||||
|
||||
testpaths = detect_testpaths(repo_root)
|
||||
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
*testpaths,
|
||||
"--cov",
|
||||
cov_target,
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
"-q",
|
||||
]
|
||||
rc, stdout, stderr = run_command(cmd, cwd=repo_root)
|
||||
combined = stdout + "\n" + stderr
|
||||
|
||||
coverage = extract_coverage(combined)
|
||||
if coverage is not None:
|
||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
cov_badge = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
tests_badge = make_badge("tests", "unknown", "red")
|
||||
|
||||
return cov_badge, tests_badge
|
||||
|
||||
|
||||
def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
|
||||
"""Run doc_coverage and collect the docs badge."""
|
||||
rc, stdout, stderr = run_command(
|
||||
[sys.executable, "-m", "devx.ci.doc_coverage"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
return make_badge("docs", "unknown", "red")
|
||||
|
||||
|
||||
def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
"""Run lint checks and collect the quality badge.
|
||||
|
||||
Runs ruff check, ruff format --check, pyright, and bandit.
|
||||
If any tool is not installed, it is skipped with a warning.
|
||||
"""
|
||||
results: list[bool] = []
|
||||
tool_names: list[str] = []
|
||||
|
||||
for cmd, name in [
|
||||
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
|
||||
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
|
||||
([sys.executable, "-m", "pyright"], "pyright"),
|
||||
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
||||
]:
|
||||
rc, _, stderr = run_command(cmd, cwd=repo_root)
|
||||
if rc == 0:
|
||||
results.append(True)
|
||||
tool_names.append(f"{name}: pass")
|
||||
else:
|
||||
results.append(False)
|
||||
# Distinguish "tool not installed" from "tool found issues"
|
||||
if "No module named" in stderr or "not found" in stderr.lower():
|
||||
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
|
||||
results[-1] = True
|
||||
tool_names.append(f"{name}: not installed (skipped)")
|
||||
else:
|
||||
tool_names.append(f"{name}: FAIL")
|
||||
click.echo(f" WARNING: {name} failed (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
|
||||
all_pass = all(results)
|
||||
click.echo(f" Quality checks: {', '.join(tool_names)}")
|
||||
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to write SVG files.
|
||||
repo_root: Repository root (auto-detected if None).
|
||||
"""
|
||||
root = repo_root or resolve_repo_root()
|
||||
click.echo(f" Repo root: {root}")
|
||||
pkg = detect_package_name(root)
|
||||
click.echo(f" Package: {pkg or 'none'}")
|
||||
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
click.echo(" Collecting coverage and tests...")
|
||||
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
||||
badges["coverage"] = cov_badge
|
||||
badges["tests"] = tests_badge
|
||||
|
||||
# 2. Documentation coverage
|
||||
click.echo(" Collecting doc coverage...")
|
||||
badges["docs"] = collect_doc_coverage(root)
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit)
|
||||
click.echo(" Collecting code quality...")
|
||||
badges["quality"] = collect_quality(root)
|
||||
|
||||
# 4. Version
|
||||
click.echo(" Collecting version...")
|
||||
version = read_version(root)
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
# 5. Python version (static)
|
||||
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=".badges",
|
||||
help="Directory to write badge SVG files.",
|
||||
)
|
||||
@click.option(
|
||||
"--repo-root",
|
||||
default=None,
|
||||
help="Repository root (auto-detected if not specified).",
|
||||
)
|
||||
def cli(output_dir: str, repo_root: str | None) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
root = Path(repo_root) if repo_root else None
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
badges = generate_badges(out, repo_root=root)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-push validation: ensure a Vikunja task exists for the branch.
|
||||
|
||||
This tool is designed to run as a git pre-push hook. It extracts the
|
||||
task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``)
|
||||
and verifies that a corresponding Vikunja task exists.
|
||||
|
||||
If the task does not exist, the hook **fails with guidance** — it does
|
||||
not auto-create the task. This prevents accidental pushes of branches
|
||||
without a planning task.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.pre_push_check --branch DEVX-31-fix-foo
|
||||
|
||||
Exit codes:
|
||||
0 — all checks passed, safe to push
|
||||
1 — validation failed (missing task, missing token, etc.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
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.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_current_branch() -> str:
|
||||
"""Return the current git branch name, or empty string on error."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def task_exists(task_id: str) -> bool:
|
||||
"""Check if a Vikunja task with the given identifier exists.
|
||||
|
||||
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return False
|
||||
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
|
||||
if any(t.get("identifier") == task_id for t in tasks):
|
||||
return True
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
return False
|
||||
|
||||
|
||||
def validate(branch: str) -> None:
|
||||
"""Run all pre-push validations for the given branch.
|
||||
|
||||
Raises ``click.ClickException`` on failure.
|
||||
"""
|
||||
if not branch or branch in ("master", "main"):
|
||||
return
|
||||
|
||||
task_id = extract_task_id(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Branch '{branch}' does not contain a task ID.\n"
|
||||
" Expected format: {prefix}-N-short-description\n"
|
||||
" Example: {prefix}-42-add-feature\n"
|
||||
" Fix: rename the branch or create a Vikunja task first:\n"
|
||||
' python -m devx.tools.create_task --title "Task title"',
|
||||
branch=branch,
|
||||
prefix=TASK_PREFIX,
|
||||
)
|
||||
)
|
||||
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
|
||||
"Set it in .env to enable full validation.",
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not task_exists(task_id):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Vikunja task {task_id} not found in project {project_id}.\n"
|
||||
" Create it first:\n"
|
||||
' python -m devx.tools.create_task --title "Task title"\n'
|
||||
" Or check that the task ID in the branch name is correct.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(_("Pre-push check passed: task {task_id} exists.", task_id=task_id))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--branch", default=None, help="Branch name (default: auto-detect from git).")
|
||||
def cli(branch: str | None) -> None:
|
||||
"""Validate pre-push preconditions for the current branch."""
|
||||
if branch is None:
|
||||
branch = get_current_branch()
|
||||
validate(branch)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/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.",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-install",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip pip install (use when deps already installed, e.g. devx came via ci extra).",
|
||||
)
|
||||
def main(
|
||||
bin_dir: str,
|
||||
extras: str,
|
||||
no_pre_commit: bool,
|
||||
no_tea_login: bool,
|
||||
no_ansible_collections: bool,
|
||||
skip_install: 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.")
|
||||
|
||||
if not skip_install:
|
||||
click.echo(f"Installing Python dependencies (extras: {extras})...")
|
||||
_install_python_deps(bin_dir, extras)
|
||||
else:
|
||||
click.echo("Skipping pip install (--skip-install).")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,1634 @@
|
||||
{
|
||||
"\n=== Summary ===": {
|
||||
"bg": "\n=== Summary ===",
|
||||
"de": "\n=== Summary ===",
|
||||
"en": "\n=== Summary ===",
|
||||
"pl": "\n=== Podsumowanie ===",
|
||||
"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!",
|
||||
"pl": "\nWszystkie kontrole pokrycia dokumentacji zakończone pomyślnie!",
|
||||
"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:",
|
||||
"pl": "\nKolejność wersji w CHANGELOG:",
|
||||
"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...",
|
||||
"pl": "\nSprawdzanie dokumentacji skryptów CI w 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...",
|
||||
"pl": "\nSprawdzanie dokumentacji modułów w 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}%)",
|
||||
"pl": "\nPokrycie dokumentacji: {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}",
|
||||
"pl": "\nGotowe! Utworzono: {created}, Zaktualizowano: {updated}, Pominięto: {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.",
|
||||
"pl": "\nBŁĄD: Pokrycie dokumentacji nie wynosi 100%. Użyj --fail-on-missing, aby to wymusić.",
|
||||
"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.",
|
||||
"pl": "\nNapraw niezgodne tagi przed utworzeniem nowych wydań. Uruchom 'python3 -m devx.ci.release --verify', aby uzyskać pełny raport.",
|
||||
"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):",
|
||||
"pl": "\nKontrola integralności NIEUDANA ({count} problemów):",
|
||||
"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.",
|
||||
"pl": "\nKontrola integralności zakończona pomyślnie — wszystkie {count} stron zweryfikowane.",
|
||||
"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}",
|
||||
"pl": "\nNajnowszy tag: {tag}",
|
||||
"ru": "\nLatest tag: {tag}",
|
||||
"zh": "\nLatest tag: {tag}"
|
||||
},
|
||||
"\nMissing documentation:": {
|
||||
"bg": "\nMissing documentation:",
|
||||
"de": "\nMissing documentation:",
|
||||
"en": "\nMissing documentation:",
|
||||
"pl": "\nBrakująca dokumentacja:",
|
||||
"ru": "\nMissing documentation:",
|
||||
"zh": "\nMissing documentation:"
|
||||
},
|
||||
"\nResult: {status}": {
|
||||
"bg": "\nResult: {status}",
|
||||
"de": "\nResult: {status}",
|
||||
"en": "\nResult: {status}",
|
||||
"pl": "\nWynik: {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).",
|
||||
"pl": "\nRecenzja #{review_id} opublikowana na PR #{pr_number} ze zdarzeniem '{event}' ({num_comments} komentarzy w tekście).",
|
||||
"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...",
|
||||
"pl": "\nUruchamianie pełnej kontroli integralności wiki...",
|
||||
"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:",
|
||||
"pl": "\nTag → Commit: zgodność:",
|
||||
"ru": "\nTag → Commit alignment:",
|
||||
"zh": "\nTag → Commit alignment:"
|
||||
},
|
||||
"\nUntagged release commits:": {
|
||||
"bg": "\nUntagged release commits:",
|
||||
"de": "\nUntagged release commits:",
|
||||
"en": "\nUntagged release commits:",
|
||||
"pl": "\nCommity wydania bez tagu:",
|
||||
"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}):",
|
||||
"pl": "\nZmiany widoczne dla użytkownika ({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!",
|
||||
"pl": "\nWeryfikacja NIEUDANA: {failures} strona(y) ma pustą lub niezgodną treść!",
|
||||
"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.",
|
||||
"pl": "\nWeryfikacja zakończona pomyślnie — wszystkie strony wiki mają poprawną treść.",
|
||||
"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...",
|
||||
"pl": "\nWeryfikowanie, czy strony wiki mają treść...",
|
||||
"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}):",
|
||||
"pl": "\nZmiany tylko w workflow ({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}",
|
||||
"pl": "\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}):",
|
||||
"pl": "\n{label} plików zmienionych ({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}):",
|
||||
"pl": "\nPliki {tag} ({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",
|
||||
"pl": " - Auto-usuwanie gałęzi po scaleniu: tak",
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"zh": " - 合并后自动删除分支: 是"
|
||||
},
|
||||
" - Block outdated branches: yes": {
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
"en": " - Block outdated branches: yes",
|
||||
"pl": " - Blokowanie nieaktualnych gałęzi: tak",
|
||||
"ru": " - Блокировать устаревшие ветки: да",
|
||||
"zh": " - 阻止过时分支: 是"
|
||||
},
|
||||
" - Block rejected reviews: yes": {
|
||||
"bg": " - Блокиране на отхвърлени рецензии: да",
|
||||
"de": " - Abgelehnte Reviews blockieren: ja",
|
||||
"en": " - Block rejected reviews: yes",
|
||||
"pl": " - Blokowanie odrzuconych recenzji: tak",
|
||||
"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)",
|
||||
"pl": " - Bezpośrednie push-e: ZABLOKOWANE (wymagają PR, użytkownicy z białej listy mogą pushować)",
|
||||
"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",
|
||||
"pl": " - Odrzucanie nieaktualnych zatwierdzeń: tak",
|
||||
"ru": " - Отклонять устаревшие одобрения: да",
|
||||
"zh": " - 忽略过时审批: 是"
|
||||
},
|
||||
" - Required approvals: {count}": {
|
||||
"bg": " - Необходими одобрения: {count}",
|
||||
"de": " - Erforderliche Genehmigungen: {count}",
|
||||
"en": " - Required approvals: {count}",
|
||||
"pl": " - Wymagane zatwierdzenia: {count}",
|
||||
"ru": " - Требуемые одобрения: {count}",
|
||||
"zh": " - 必需审批数: {count}"
|
||||
},
|
||||
" - Required status checks: {checks}": {
|
||||
"bg": " - Необходими проверки на състоянието: {checks}",
|
||||
"de": " - Erforderliche Status-Checks: {checks}",
|
||||
"en": " - Required status checks: {checks}",
|
||||
"pl": " - Wymagane kontrole statusu: {checks}",
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Created: {title}": {
|
||||
"bg": " Created: {title}",
|
||||
"de": " Created: {title}",
|
||||
"en": " Created: {title}",
|
||||
"pl": " Utworzono: {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!",
|
||||
"pl": " BŁĄD: {title} — treść niezgodna lub pusta!",
|
||||
"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}",
|
||||
"pl": " BRAK: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"bg": " MISSING: {module}",
|
||||
"de": " MISSING: {module}",
|
||||
"en": " MISSING: {module}",
|
||||
"pl": " BRAK: {module}",
|
||||
"ru": " MISSING: {module}",
|
||||
"zh": " MISSING: {module}"
|
||||
},
|
||||
" MISSING: {script}": {
|
||||
"bg": " MISSING: {script}",
|
||||
"de": " MISSING: {script}",
|
||||
"en": " MISSING: {script}",
|
||||
"pl": " BRAK: {script}",
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"en": " OK: devx {cmd}",
|
||||
"pl": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"bg": " OK: {module}",
|
||||
"de": " OK: {module}",
|
||||
"en": " OK: {module}",
|
||||
"pl": " OK: {module}",
|
||||
"ru": " OK: {module}",
|
||||
"zh": " OK: {module}"
|
||||
},
|
||||
" OK: {script}": {
|
||||
"bg": " OK: {script}",
|
||||
"de": " OK: {script}",
|
||||
"en": " OK: {script}",
|
||||
"pl": " 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)",
|
||||
"pl": " OK: {title} ({chars} znaków)",
|
||||
"ru": " OK: {title} ({chars} chars)",
|
||||
"zh": " OK: {title} ({chars} chars)"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"bg": " Updated: {title}",
|
||||
"de": " Updated: {title}",
|
||||
"en": " Updated: {title}",
|
||||
"pl": " Zaktualizowano: {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.",
|
||||
"pl": "--skip-build: pomijanie budowania pakietu i publikacji PyPI.",
|
||||
"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",
|
||||
"pl": "=== Weryfikacja zgodności wydań ===\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}",
|
||||
"pl": "Ostrzeżenie sondowania API: {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.",
|
||||
"pl": "Wszystkie testy molecule zakończone pomyślnie.",
|
||||
"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.",
|
||||
"pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.",
|
||||
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||
},
|
||||
"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}",
|
||||
"pl": "Zmiana wersji: {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...",
|
||||
"pl": "Sprawdzanie dokumentacji poleceń CLI...",
|
||||
"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}",
|
||||
"pl": "Polecenie nie powiodło się ({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)",
|
||||
"pl": "Porównywanie {base}..{head} ({count} zmienionych plików)",
|
||||
"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}...",
|
||||
"pl": "Konfigurowanie ochrony gałęzi dla {branch}...",
|
||||
"ru": "Настройка защиты ветки {branch}...",
|
||||
"zh": "正在配置 {branch} 的分支保护..."
|
||||
},
|
||||
"Configuring repository settings...": {
|
||||
"bg": "Конфигуриране на настройките на хранилището...",
|
||||
"de": "Repository-Einstellungen konfigurieren...",
|
||||
"en": "Configuring repository settings...",
|
||||
"pl": "Konfigurowanie ustawień repozytorium...",
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Configuration OK: [tool.devx] present, devx versions consistent.": {
|
||||
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
|
||||
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
|
||||
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
|
||||
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
|
||||
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||
},
|
||||
"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.",
|
||||
"pl": "Nie udało się wyodrębnić konwencjonalnej wiadomości commit z commitów PR.",
|
||||
"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.",
|
||||
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}. Każdy PR musi mieć odpowiadające zadanie Vikunja.",
|
||||
"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}",
|
||||
"pl": "Nie znaleziono __version__ w {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.",
|
||||
"pl": "Nie udało się przeanalizować czasu wykonania testu z wyjścia.",
|
||||
"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}",
|
||||
"pl": "Utworzono zgłoszenie #{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.",
|
||||
"pl": "Utworzono commit wydania.",
|
||||
"ru": "Created release commit.",
|
||||
"zh": "Created release commit."
|
||||
},
|
||||
"devx version mismatch across extras: {detail}": {
|
||||
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||
"en": "devx version mismatch across extras: {detail}",
|
||||
"pl": "niezgodność wersji devx między extras: {detail}",
|
||||
"ru": "несоответствие версии devx между extras: {detail}",
|
||||
"zh": "devx 版本在 extras 之间不一致: {detail}"
|
||||
},
|
||||
"Docker daemon already running": {
|
||||
"bg": "Докер демонът вече работи",
|
||||
"de": "Docker-Daemon läuft bereits",
|
||||
"en": "Docker daemon already running",
|
||||
"pl": "Demon Docker już uruchomiony",
|
||||
"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",
|
||||
"pl": "Nie udało się uruchomić demona Docker",
|
||||
"ru": "Не удалось запустить Docker-демон",
|
||||
"zh": "Docker 守护进程启动失败"
|
||||
},
|
||||
"Docker daemon started": {
|
||||
"bg": "Docker daemon started",
|
||||
"de": "Docker-Daemon gestartet",
|
||||
"en": "Docker daemon started",
|
||||
"pl": "Demon Docker uruchomiony",
|
||||
"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.",
|
||||
"pl": "Tryb dry-run: na gałęzi '{branch}' (nie master). Niektóre kontrole mogą zachowywać się inaczej.",
|
||||
"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.",
|
||||
"pl": "BŁĄD: REPO_TOKEN nie jest ustawiony.",
|
||||
"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.",
|
||||
"pl": "BŁĄD: Nazwa repozytorium nie jest określona. Użyj --repo lub ustaw 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:",
|
||||
"pl": "BŁĄD: Kontrola zgodności tagów nie powiodła się. Istniejące tagi są niezgodne:",
|
||||
"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.",
|
||||
"pl": "BŁĄD: VIKUNJA_TOKEN nie jest ustawiony.",
|
||||
"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}",
|
||||
"pl": "BŁĄD: mapping.json nie znaleziono w {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}",
|
||||
"pl": "NIEUDANE: {pair} zakończone kodem {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}",
|
||||
"pl": "Nie udało się utworzyć zgłoszenia przez 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.",
|
||||
"pl": "Znaleziono {count} istniejących stron wiki.",
|
||||
"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.",
|
||||
"pl": "GITEA_URL/REPO_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.",
|
||||
"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}'.",
|
||||
"pl": "Wygenerowano {file} z prefiksem '{prefix}'.",
|
||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||
"zh": "Generated {file} with prefix '{prefix}'."
|
||||
},
|
||||
"Gitea PyPI registry: {tag} already published — continuing.": {
|
||||
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
||||
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
||||
"en": "Gitea PyPI registry: {tag} already published — continuing.",
|
||||
"pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.",
|
||||
"ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.",
|
||||
"zh": "Gitea PyPI registry: {tag} 已发布 — 继续。"
|
||||
},
|
||||
"Gitea release {tag} already exists — skipping creation.": {
|
||||
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
|
||||
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
|
||||
"en": "Gitea release {tag} already exists — skipping creation.",
|
||||
"pl": "Wydanie Gitea {tag} już istnieje — pomijanie tworzenia.",
|
||||
"ru": "Gitea release {tag} уже существует — пропуск создания.",
|
||||
"zh": "Gitea release {tag} 已存在 — 跳过创建。"
|
||||
},
|
||||
"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.",
|
||||
"pl": "HEAD jest commitem wydania ('{msg}') ale tag {tag} brakuje. Naprawa przez utworzenie tagu.",
|
||||
"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.",
|
||||
"pl": "HEAD jest commitem wydania dla v{version} ale tag {tag} wskazuje na inny commit ({tag_commit} vs HEAD {head_commit}). Wskazuje to na niezgodność tag/commit.",
|
||||
"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.",
|
||||
"pl": "HEAD jest już commitem wydania ('{msg}') a tag {tag} wskazuje na HEAD. Pomijanie.",
|
||||
"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."
|
||||
},
|
||||
"HEAD is not a release commit for {tag} — skipping publish.": {
|
||||
"bg": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"de": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"en": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"pl": "HEAD nie jest commitem wydania dla {tag} — pomijanie publikacji.",
|
||||
"ru": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"zh": "HEAD is not a release commit for {tag} — skipping publish."
|
||||
},
|
||||
"HTTP error: {status} — {message}": {
|
||||
"bg": "HTTP грешка: {status} — {message}",
|
||||
"de": "HTTP-Fehler: {status} — {message}",
|
||||
"en": "HTTP error: {status} — {message}",
|
||||
"pl": "Błąd HTTP: {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.",
|
||||
"pl": "HTTP {status} Forbidden — twój token nie ma uprawnień administratora.\nUpewnij się, że token należy do właściciela repozytorium lub administratora organizacji.\nAlternatywnie skonfiguruj ochronę gałęzi ręcznie w Ustawienia → Gałęzie.",
|
||||
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
|
||||
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
||||
},
|
||||
"Host Docker not available, starting local dockerd...": {
|
||||
"bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
|
||||
"de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
|
||||
"en": "Host Docker not available, starting local dockerd...",
|
||||
"pl": "Host Docker niedostępny, uruchamianie lokalnego dockerd...",
|
||||
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
|
||||
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
|
||||
},
|
||||
"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}",
|
||||
"pl": "Commit infrastruktury (bez ID zadania DEVX-N), pomijanie aktualizacji Vikunja: {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.",
|
||||
"pl": "Testy integracyjne anulowane — inny runner zakończył się niepowodzeniem.",
|
||||
"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}",
|
||||
"pl": "Testy integracyjne zakończone niepowodzeniem z kodem {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.",
|
||||
"pl": "Testy integracyjne zakończone pomyślnie.",
|
||||
"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}",
|
||||
"pl": "Lint nie powiódł się — odmowa wydania. Najpierw napraw błędy lint.\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.",
|
||||
"pl": "Lint zakończony pomyślnie.",
|
||||
"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.",
|
||||
"pl": "Mapowany plik {file} jest pusty. Zaktualizuj treść lub usuń z 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.",
|
||||
"pl": "Mapowany plik {file} nie znaleziony. Zaktualizuj mapping.json lub utwórz plik.",
|
||||
"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 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.",
|
||||
"pl": "Scalanie nie powiodło się z HTTP {status}: {message}\nSprawdź czy PR jest gotowy i masz uprawnienia do scalania.",
|
||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Module {mod} has no main() function": {
|
||||
"bg": "Модул {mod} няма функция main()",
|
||||
"de": "Modul {mod} hat keine main()-Funktion",
|
||||
"en": "Module {mod} has no main() function",
|
||||
"pl": "Moduł {mod} nie ma funkcji main()",
|
||||
"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}",
|
||||
"pl": "Katalog molecule nie znaleziony: {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.",
|
||||
"pl": "Świetnie! Wydanie Gitea {tag} utworzone.",
|
||||
"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}",
|
||||
"pl": "Świetnie! PR #{pr_number} squash-merged z tytułem: {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.",
|
||||
"pl": "Świetnie! Wydanie v{version} otagowane i wypchnięte. Workflow publikacji zostanie uruchomiony.",
|
||||
"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.",
|
||||
"pl": "Świetnie! Zadanie Vikunja {task_id} (ID {vikunja_id}) zaktualizowane i oznaczone jako ukończone.",
|
||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"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}.",
|
||||
"pl": "Brak zmian między {base} i {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.",
|
||||
"pl": "Brak zmian w staging — wersja i changelog są już aktualne.",
|
||||
"ru": "No staged changes — version and changelog already up to date.",
|
||||
"zh": "No staged changes — version and changelog already up to date."
|
||||
},
|
||||
"No tag found — skipping publish.": {
|
||||
"bg": "No tag found — skipping publish.",
|
||||
"de": "No tag found — skipping publish.",
|
||||
"en": "No tag found — skipping publish.",
|
||||
"pl": "Nie znaleziono tagu — pomijanie publikacji.",
|
||||
"ru": "No tag found — skipping publish.",
|
||||
"zh": "No tag found — skipping publish."
|
||||
},
|
||||
"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.",
|
||||
"pl": "Nie znaleziono tagów — traktowanie wszystkich zmian jako widocznych dla użytkownika.",
|
||||
"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.",
|
||||
"pl": "Nie znaleziono ID zadania ({prefix}-N) w wiadomości commit: {msg}. Każdy commit nie-infrastrukturalny musi mieć ID zadania.",
|
||||
"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.",
|
||||
"pl": "Nie znaleziono nieopublikowanych zmian. Nic do wydania.",
|
||||
"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.",
|
||||
"pl": "Brak zmian widocznych dla użytkownika od {tag} — tylko pliki workflow/infrastruktury uległy zmianie. Pomijanie wydania.",
|
||||
"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.",
|
||||
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone. Publikowanie COMMENT zamiast tego.",
|
||||
"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",
|
||||
"pl": "Ups! Wiadomość commit musi być w formacie conventional commit.\n Oczekiwano: <typ>: <opis>\n Otrzymano: {subject}\n Dozwolone typy: 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.",
|
||||
"pl": "Ups! Nie dołączaj ID zadania ({prefix}-N) w commitach gałęzi feature.\n ID zadania zostanie dodane automatycznie przy scaleniu przez 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}",
|
||||
"pl": "Ups! Publikacja w rejestrze Gitea PyPI nie powiodła się:\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}",
|
||||
"pl": "Ups! Commit gałęzi master musi być w formacie conventional po ID zadania.\n Oczekiwano: {prefix}-N: <typ>: <opis>\n Otrzymano: {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}",
|
||||
"pl": "Ups! Commity gałęzi master muszą zaczynać się od ID zadania.\n Oczekiwano: {prefix}-N: <conwencjonalna wiadomość commit>\n Otrzymano: {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 branch name '{branch}'. Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).": {
|
||||
"bg": "Ой! Не е намерен ID на задача в името на клона '{branch}'. Имената на клонове трябва да включват префикса за ID на задача (напр. DEVX-31-fix-bug).",
|
||||
"de": "Hoppla! Keine Task-ID im Branch-Namen '{branch}' gefunden. Branch-Namen müssen das Task-ID-Präfix enthalten (z.B. DEVX-31-fix-bug).",
|
||||
"en": "Oops! No task ID found in branch name '{branch}'. Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).",
|
||||
"pl": "Ups! Nie znaleziono ID zadania w nazwie gałęzi '{branch}'. Nazwy gałęzi muszą zawierać prefiks ID zadania (np., DEVX-31-fix-bug).",
|
||||
"ru": "Ой! ID задачи не найден в имени ветки '{branch}'. Имена веток должны включать префикс ID задачи (например, DEVX-31-fix-bug).",
|
||||
"zh": "哎呀!在分支名称 '{branch}' 中未找到任务 ID。分支名称必须包含任务 ID 前缀(例如 DEVX-31-fix-bug)。"
|
||||
},
|
||||
"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}",
|
||||
"pl": "Ups! Tytuł PR musi być w formacie '{prefix}-N: <tytuł zadania>'.\n Oczekiwano: {task_id}: <tytuł zadania>\n Otrzymano: {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}",
|
||||
"pl": "Ups! Niezgodność ID zadania w tytule PR.\n ID zadania z gałęzi: {task_id}\n Tytuł PR: {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}",
|
||||
"pl": "Ups! Budowanie pakietu nie powiodło się:\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}",
|
||||
"pl": "Ups! Publikacja PyPI nie powiodła się:\n{stderr}",
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
"en": "PASSED: {pair}",
|
||||
"pl": "UDANE: {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}",
|
||||
"pl": "Numer PR musi być liczbą całkowitą, otrzymano: {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}",
|
||||
"pl": "Tytuł PR nie pasuje do tytułu zadania Vikunja.\n Oczekiwano: {expected}\n Otrzymano: {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.",
|
||||
"pl": "PYPI_TOKEN nie ustawiony i brak URL rejestru — pomijanie publikacji PyPI. Bez obaw, utworzymy tylko wydanie Gitea.",
|
||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
|
||||
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
||||
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
||||
"en": "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
|
||||
"pl": "Przeanalizowano owner={owner}, repo={repo} z DEVX_REPO_NAME",
|
||||
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
||||
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
||||
},
|
||||
"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.",
|
||||
"pl": "Kontrola szybkości pojedynczego testu NIEUDANA: {count} test(ów) przekracza limit {limit}s.",
|
||||
"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."
|
||||
},
|
||||
"Provide a commit message file or use --git.": {
|
||||
"bg": "Provide a commit message file or use --git.",
|
||||
"de": "Provide a commit message file or use --git.",
|
||||
"en": "Provide a commit message file or use --git.",
|
||||
"pl": "Podaj plik komunikatu commitu lub użyj --git.",
|
||||
"ru": "Provide a commit message file or use --git.",
|
||||
"zh": "Provide a commit message file or use --git."
|
||||
},
|
||||
"Published to Gitea PyPI registry.": {
|
||||
"bg": "Публикувано в Gitea PyPI registry.",
|
||||
"de": "In der Gitea PyPI-Registry veröffentlicht.",
|
||||
"en": "Published to Gitea PyPI registry.",
|
||||
"pl": "Opublikowano w rejestrze Gitea PyPI.",
|
||||
"ru": "Опубликовано в Gitea PyPI registry.",
|
||||
"zh": "已发布到 Gitea PyPI registry。"
|
||||
},
|
||||
"Published to PyPI.": {
|
||||
"bg": "Публикувано в PyPI.",
|
||||
"de": "In PyPI veröffentlicht.",
|
||||
"en": "Published to PyPI.",
|
||||
"pl": "Opublikowano w PyPI.",
|
||||
"ru": "Опубликовано в PyPI.",
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"Publishing release {tag}...": {
|
||||
"bg": "Publishing release {tag}...",
|
||||
"de": "Publishing release {tag}...",
|
||||
"en": "Publishing release {tag}...",
|
||||
"pl": "Publikowanie wydania {tag}...",
|
||||
"ru": "Publishing release {tag}...",
|
||||
"zh": "Publishing release {tag}..."
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"bg": "Pushed release commit to master.",
|
||||
"de": "Pushed release commit to master.",
|
||||
"en": "Pushed release commit to master.",
|
||||
"pl": "Wypchnięto commit wydania do master.",
|
||||
"ru": "Pushed release commit to master.",
|
||||
"zh": "Pushed release commit to master."
|
||||
},
|
||||
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
|
||||
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
|
||||
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
|
||||
"en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
|
||||
"pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}",
|
||||
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
|
||||
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
"en": "Release creation failed: {error}",
|
||||
"pl": "Tworzenie wydania nie powiodło się: {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}'.",
|
||||
"pl": "Wydanie musi być uruchomione na master, obecnie na '{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}",
|
||||
"pl": "Repo musi być w formacie 'owner/name', otrzymano: {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.",
|
||||
"pl": "Konfiguracja repozytorium zakończona.",
|
||||
"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}",
|
||||
"pl": "Katalog ról nie znaleziony: {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})",
|
||||
"pl": "Indeks runnera {index} poza zakresem (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...",
|
||||
"pl": "Uruchamianie kontroli lint...",
|
||||
"ru": "Running lint checks...",
|
||||
"zh": "Running lint checks..."
|
||||
},
|
||||
"Running tests...": {
|
||||
"bg": "Running tests...",
|
||||
"de": "Running tests...",
|
||||
"en": "Running tests...",
|
||||
"pl": "Uruchamianie testów...",
|
||||
"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}",
|
||||
"pl": "Uruchamianie: {scenario} na {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.",
|
||||
"pl": "Pomijanie wypchnięcia commit — brak zmian w staging.",
|
||||
"ru": "Skipping commit push — no staged changes.",
|
||||
"zh": "Skipping commit push — no staged changes."
|
||||
},
|
||||
"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...",
|
||||
"pl": "Synchronizowanie {count} stron dokumentacji do 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.",
|
||||
"pl": "Kontrola zgodności tagów nie powiodła się.",
|
||||
"ru": "Tag consistency check failed.",
|
||||
"zh": "Tag consistency check failed."
|
||||
},
|
||||
"Tag is required (or use --from-tag).": {
|
||||
"bg": "Tag is required (or use --from-tag).",
|
||||
"de": "Tag is required (or use --from-tag).",
|
||||
"en": "Tag is required (or use --from-tag).",
|
||||
"pl": "Tag jest wymagany (lub użyj --from-tag).",
|
||||
"ru": "Tag is required (or use --from-tag).",
|
||||
"zh": "Tag is required (or use --from-tag)."
|
||||
},
|
||||
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
||||
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||
},
|
||||
"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.",
|
||||
"pl": "Tag v{version} już istniał. Workflow publikacji powinien już być uruchomiony.",
|
||||
"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.",
|
||||
"pl": "Tag {tag} już istnieje i wskazuje na HEAD. Pomijanie tworzenia.",
|
||||
"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.",
|
||||
"pl": "Tag {tag} już istnieje ale wskazuje na {tag_commit} (oczekiwano HEAD {head_commit}). Wskazuje to na niezgodność tag/commit. Uruchom 'python3 -m devx.ci.release --verify', aby uzyskać szczegóły.",
|
||||
"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}",
|
||||
"pl": "ID zadania: {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.",
|
||||
"pl": "Test '{name}' trwał {elapsed:.2f}s (limit: {limit}s). Optymalizuj: użyj lżejszych fixtures, zmniejsz I/O, lub mockuj zewnętrzne wywołania.",
|
||||
"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}",
|
||||
"pl": "Testy nie powiodły się — odmowa wydania. Najpierw napraw niepowodzenia testów.\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.",
|
||||
"pl": "Testy zakończone pomyślnie.",
|
||||
"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).",
|
||||
"pl": "Testy jednostkowe zakończone pomyślnie w {duration:.2f}s (poniżej limitu {max}s, wszystkie testy poniżej limitu {single}s na test).",
|
||||
"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.",
|
||||
"pl": "Testy jednostkowe zbyt wolne: {duration:.2f}s (maks. dozwolone: {max}s).\n Naprawa: uruchom 'make pytest-cov' do profilowania, następnie zoptymalizuj wolne testy.\n Wskazówka: unikaj niepotrzebnych importów, użyj lżejszych mocków, lub buforuj 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}",
|
||||
"pl": "Nieznana kategoria kontroli '{check}'. Dostępne: 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}",
|
||||
"pl": "Zaktualizowano wersję w {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}",
|
||||
"pl": "Zaktualizowano {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.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Jest to wymagane w CI do walidacji tytułów PR.",
|
||||
"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}",
|
||||
"pl": "Plik wersji: {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.",
|
||||
"pl": "Błąd API Vikunja (HTTP {status}): {message}. Zadanie {task_id} NIE zostało zaktualizowane. Scalenie powiodło się ale zadanie Vikunja wymaga ręcznej aktualizacji.",
|
||||
"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.",
|
||||
"pl": "OSTRZEŻENIE: --skip-tests przekazane — pomijanie weryfikacji testów.",
|
||||
"ru": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"zh": "WARNING: --skip-tests passed — skipping test verification."
|
||||
},
|
||||
"WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). Delete .taskid from the repo — branch name is the sole source of truth.": {
|
||||
"bg": "ВНИМАНИЕ: Файлът .taskid ({file_id}) е остарял и не съвпада с името на клона ({branch_id}). Изтрийте .taskid от хранилището — името на клона е единственият източник на истината.",
|
||||
"de": "WARNUNG: Die Datei .taskid ({file_id}) ist veraltet und stimmt nicht mit dem Branch-Namen ({branch_id}) überein. Löschen Sie .taskid aus dem Repo — der Branch-Name ist die einzige Wahrheitsquelle.",
|
||||
"en": "WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). Delete .taskid from the repo — branch name is the sole source of truth.",
|
||||
"pl": "OSTRZEŻENIE: plik .taskid ({file_id}) jest przestarzały i niezgodny z nazwą gałęzi ({branch_id}). Usuń .taskid z repozytorium — nazwa gałęzi jest jedynym źródłem prawdy.",
|
||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||
},
|
||||
"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.",
|
||||
"pl": "Ostrzeżenie: nie udało się pobrać tagów z 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)",
|
||||
"pl": "Kontrola integralności wiki nie powiodła się — {count} problem(ów)",
|
||||
"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",
|
||||
"pl": "Weryfikacja wiki nie powiodła się — {failures} strona(y) pusta lub niezgodna",
|
||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[tool.devx] missing required keys: {keys}": {
|
||||
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||
"en": "[tool.devx] missing required keys: {keys}",
|
||||
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
|
||||
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
|
||||
"zh": "[tool.devx] 缺少必需的键: {keys}"
|
||||
},
|
||||
"[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}",
|
||||
"pl": "[dry-run] Utworzono by 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}",
|
||||
"pl": "[dry-run] Utworzono by 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}",
|
||||
"pl": "[dry-run] Utworzono by 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",
|
||||
"pl": "[dry-run] Wypchnięto by commit do 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)",
|
||||
"pl": "[dry-run] Zsynchronizowano by stronę: {title} ({chars} znaków)",
|
||||
"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}",
|
||||
"pl": "[dry-run] Zaktualizowano by {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}",
|
||||
"pl": "[dry-run] Zaktualizowano by {init}",
|
||||
"ru": "[dry-run] Would update {init}",
|
||||
"zh": "[dry-run] Would update {init}"
|
||||
},
|
||||
"active": {
|
||||
"bg": "активен",
|
||||
"de": "aktiv",
|
||||
"en": "active",
|
||||
"pl": "aktywny",
|
||||
"ru": "активен",
|
||||
"zh": "活跃"
|
||||
},
|
||||
"completed": {
|
||||
"bg": "завършен",
|
||||
"de": "abgeschlossen",
|
||||
"en": "completed",
|
||||
"pl": "ukończony",
|
||||
"ru": "завершён",
|
||||
"zh": "已完成"
|
||||
},
|
||||
"failed": {
|
||||
"bg": "неуспешен",
|
||||
"de": "fehlgeschlagen",
|
||||
"en": "failed",
|
||||
"pl": "nieudany",
|
||||
"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}",
|
||||
"pl": "polecenie git nie powiodło się ({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.",
|
||||
"pl": "git-cliff wygenerował pusty changelog dla v{version}. Sprawdź cliff.toml i historię commitów.",
|
||||
"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.",
|
||||
"pl": "git-cliff zwrócił pustą wersję.",
|
||||
"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).",
|
||||
"pl": "git-cliff zwrócił nieprawidłowy format wersji: {version}. Oczekiwano semver (np., 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",
|
||||
"pl": "w toku",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
},
|
||||
"inactive": {
|
||||
"bg": "неактивен",
|
||||
"de": "inaktiv",
|
||||
"en": "inactive",
|
||||
"pl": "nieaktywny",
|
||||
"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}",
|
||||
"pl": "klucze i wartości mapping.json muszą być ciągami znaków, otrzymano {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}",
|
||||
"pl": "mapping.json musi być słownikiem ścieżka-pliku -> tytuł-strony, otrzymano {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",
|
||||
"pl": "oczekujący",
|
||||
"ru": "ожидает",
|
||||
"zh": "待处理"
|
||||
},
|
||||
"pyproject.toml not found in current directory.": {
|
||||
"bg": "pyproject.toml не е намерен в текущата директория.",
|
||||
"de": "pyproject.toml im aktuellen Verzeichnis nicht gefunden.",
|
||||
"en": "pyproject.toml not found in current directory.",
|
||||
"pl": "nie znaleziono pyproject.toml w bieżącym katalogu.",
|
||||
"ru": "pyproject.toml не найден в текущей директории.",
|
||||
"zh": "在当前目录中未找到 pyproject.toml。"
|
||||
},
|
||||
"unknown": {
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
"en": "unknown",
|
||||
"pl": "nieznany",
|
||||
"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.",
|
||||
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
},
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
|
||||
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
|
||||
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
|
||||
},
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
|
||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
|
||||
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
|
||||
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.": {
|
||||
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
|
||||
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
|
||||
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
|
||||
},
|
||||
"Could not detect current branch: {error}": {
|
||||
"bg": "Не може да се определи текущия клон: {error}",
|
||||
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||
"en": "Could not detect current branch: {error}",
|
||||
"pl": "Nie można wykryć bieżącej gałęzi: {error}",
|
||||
"ru": "Не удалось определить текущую ветку: {error}",
|
||||
"zh": "无法检测当前分支: {error}"
|
||||
},
|
||||
"Created PR #{index}: {title}\n {url}": {
|
||||
"bg": "Създаден PR #{index}: {title}\n {url}",
|
||||
"de": "PR erstellt #{index}: {title}\n {url}",
|
||||
"en": "Created PR #{index}: {title}\n {url}",
|
||||
"pl": "Utworzono PR #{index}: {title}\n {url}",
|
||||
"ru": "Создан PR #{index}: {title}\n {url}",
|
||||
"zh": "已创建 PR #{index}: {title}\n {url}"
|
||||
},
|
||||
"Created Vikunja task: {identifier} (id={task_id})": {
|
||||
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
|
||||
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
|
||||
"en": "Created Vikunja task: {identifier} (id={task_id})",
|
||||
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
|
||||
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
|
||||
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
|
||||
},
|
||||
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
|
||||
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
|
||||
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
|
||||
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
|
||||
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
|
||||
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
|
||||
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
|
||||
},
|
||||
"PR already exists: #{index} — {url}": {
|
||||
"bg": "PR вече съществува: #{index} — {url}",
|
||||
"de": "PR existiert bereits: #{index} — {url}",
|
||||
"en": "PR already exists: #{index} — {url}",
|
||||
"pl": "PR już istnieje: #{index} — {url}",
|
||||
"ru": "PR уже существует: #{index} — {url}",
|
||||
"zh": "PR 已存在: #{index} — {url}"
|
||||
},
|
||||
"Pre-push check passed: task {task_id} exists.": {
|
||||
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
|
||||
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
|
||||
"en": "Pre-push check passed: task {task_id} exists.",
|
||||
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
|
||||
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
|
||||
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
|
||||
},
|
||||
"REPO_TOKEN is not set. Required to create a PR.": {
|
||||
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||
"en": "REPO_TOKEN is not set. Required to create a PR.",
|
||||
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
|
||||
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
|
||||
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
|
||||
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
|
||||
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
|
||||
},
|
||||
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
|
||||
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
|
||||
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
|
||||
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
|
||||
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
|
||||
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
|
||||
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
|
||||
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
|
||||
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
|
||||
},
|
||||
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
|
||||
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
|
||||
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
|
||||
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
|
||||
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
|
||||
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
|
||||
},
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
|
||||
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="20" role="img"
|
||||
aria-label="tests: 2189 passing">
|
||||
<title>tests: 2189 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">2189 passing</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 909 B |
@@ -0,0 +1,801 @@
|
||||
"""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_ensure_label_creates_when_others_exist(self) -> None:
|
||||
"""When labels exist but none match the target name, create a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_labels = MagicMock(
|
||||
return_value=[{"name": "bug", "color": "ff0000"}, {"name": "docs", "color": "007ec6"}]
|
||||
)
|
||||
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_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_ensure_branch_protection_creates_when_none_match(self) -> None:
|
||||
"""When existing protections exist but none match the target branch, create a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_branch_protections = MagicMock(
|
||||
return_value=[{"branch_name": "develop"}, {"branch_name": "staging"}]
|
||||
)
|
||||
client.create_branch_protection = MagicMock(return_value={"id": 5, "branch_name": "master"})
|
||||
|
||||
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
|
||||
assert result["id"] == 5
|
||||
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
|
||||
|
||||
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_create_pr(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"number": 15, "html_url": "https://git.example.com/pr/15"})
|
||||
)
|
||||
result = client.create_pr(title="DEVX-42: Add feature", head="DEVX-42-fix", body="desc")
|
||||
assert result["number"] == 15
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"title": "DEVX-42: Add feature", "head": "DEVX-42-fix", "base": "master", "body": "desc"},
|
||||
)
|
||||
|
||||
def test_create_pr_no_body(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"number": 16, "html_url": "https://git.example.com/pr/16"})
|
||||
)
|
||||
result = client.create_pr(title="DEVX-43: Fix bug", head="DEVX-43-fix")
|
||||
assert result["number"] == 16
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert "body" not in call_kwargs["json"]
|
||||
|
||||
def test_create_pr_custom_base(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"number": 17}))
|
||||
client.create_pr(title="Test", head="branch", base="develop")
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["base"] == "develop"
|
||||
|
||||
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_list_prs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
|
||||
)
|
||||
|
||||
result = client.list_prs()
|
||||
assert len(result) == 2
|
||||
assert result[0]["number"] == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls",
|
||||
params={"state": "all"},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_list_prs_with_params(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
|
||||
|
||||
result = client.list_prs(state="closed", q="docs")
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls",
|
||||
params={"state": "closed", "q": "docs"},
|
||||
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},
|
||||
)
|
||||
|
||||
def test_list_comments(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
|
||||
)
|
||||
|
||||
result = client.list_comments(42)
|
||||
assert len(result) == 2
|
||||
assert result[0]["comment"] == "first"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://work.example.com/tasks/42/comments",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_update_task_safe(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
side_effect=[
|
||||
_mock_response({"id": 42, "title": "My task", "done": False}),
|
||||
_mock_response({"id": 42, "title": "My task", "done": True}),
|
||||
]
|
||||
)
|
||||
|
||||
result = client.update_task_safe(42, done=True)
|
||||
assert result["done"] is True
|
||||
assert result["title"] == "My task"
|
||||
assert client._session.request.call_count == 2
|
||||
client._session.request.assert_any_call(
|
||||
"GET",
|
||||
"https://work.example.com/tasks/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
client._session.request.assert_any_call(
|
||||
"POST",
|
||||
"https://work.example.com/tasks/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"id": 42, "title": "My task", "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
|
||||
|
||||
def test_vikunja_create_task(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"id": 1, "identifier": "DEVX-1", "title": "Test"})
|
||||
)
|
||||
result = client.create_task(6, "Test", "<p>desc</p>")
|
||||
assert result["identifier"] == "DEVX-1"
|
||||
client._session.request.assert_called_once_with(
|
||||
"PUT",
|
||||
"https://work.example.com/projects/6/tasks",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"title": "Test", "description": "<p>desc</p>"},
|
||||
)
|
||||
|
||||
def test_vikunja_create_task_no_description(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"id": 2, "identifier": "DEVX-2", "title": "No desc"})
|
||||
)
|
||||
result = client.create_task(6, "No desc")
|
||||
assert result["id"] == 2
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["description"] == ""
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,400 @@
|
||||
"""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_extracts_from_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_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("feature-branch") == ""
|
||||
|
||||
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# Branch name takes priority, stale .taskid should produce deprecation warning
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
captured = capsys.readouterr()
|
||||
combined = captured.out + captured.err
|
||||
assert "WARNING" in combined
|
||||
assert "deprecated" in combined
|
||||
assert "DEVX-60" in combined
|
||||
assert "DEVX-19" in combined
|
||||
|
||||
def test_no_warning_when_taskid_file_absent(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" not in captured.out
|
||||
|
||||
def test_no_warning_when_taskid_file_matches_branch(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
"""No warning when .taskid file content matches the branch task ID."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" not in captured.out
|
||||
|
||||
def test_no_warning_when_taskid_file_empty(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
"""No warning when .taskid file exists but is empty."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("\n")
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" not in captured.out
|
||||
|
||||
|
||||
# -- 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)
|
||||
|
||||
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 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)
|
||||
|
||||
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_raises_no_rebase(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When branch is behind master, auto-merge should NOT rebase.
|
||||
|
||||
Auto-rebasing creates a feedback loop: the force-push triggers a new
|
||||
pull_request synchronize event, which starts a new CI run, which runs
|
||||
auto-merge again, which rebases again, etc.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
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
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "behind master" in result.output.lower()
|
||||
assert "rebase manually" in result.output.lower()
|
||||
# Must NOT have called merge_pr twice (no retry after rebase)
|
||||
assert mock_client.merge_pr.call_count == 1
|
||||
|
||||
@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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
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_merge_behind_master_does_not_force_push(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""Verify no git commands are run when branch is behind master."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
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:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
# No git commands should be run (no rebase, no push)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
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"])
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for devx.tools.check_config."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_config import cli
|
||||
|
||||
|
||||
class TestCheckConfig:
|
||||
def test_valid_config(self, tmp_path: Path) -> None:
|
||||
"""A valid [tool.devx] section with consistent versions passes."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
'[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 0
|
||||
assert "Configuration OK" in result.output
|
||||
|
||||
def test_missing_tool_devx_section(self, tmp_path: Path) -> None:
|
||||
"""Missing [tool.devx] section fails with error."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "missing required keys" in result.output
|
||||
|
||||
def test_partial_tool_devx_section(self, tmp_path: Path) -> None:
|
||||
"""Partial [tool.devx] section fails with missing keys."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n')
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "missing required keys" in result.output
|
||||
assert "vikunja_project_id" in result.output
|
||||
assert "repo_owner" in result.output
|
||||
assert "repo_name" in result.output
|
||||
|
||||
def test_version_mismatch(self, tmp_path: Path) -> None:
|
||||
"""Version mismatch across extras fails."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
"[project.optional-dependencies]\n"
|
||||
'ci = ["devx>=0.15.0"]\n'
|
||||
'dev = ["devx>=0.14.2"]\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "version mismatch" in result.output
|
||||
|
||||
def test_no_pyproject_file(self, tmp_path: Path) -> None:
|
||||
"""Missing pyproject.toml fails."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.output
|
||||
|
||||
def test_no_extras_passes(self, tmp_path: Path) -> None:
|
||||
"""No optional-dependencies with devx is fine (no versions to compare)."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 0
|
||||
assert "Configuration OK" in result.output
|
||||
|
||||
def test_single_extra_passes(self, tmp_path: Path) -> None:
|
||||
"""Single extra with devx version is fine (no mismatch possible)."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
'[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 0
|
||||
@@ -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)
|
||||
@@ -0,0 +1,393 @@
|
||||
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"
|
||||
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
|
||||
# Include dynamic keys since collect_keys now adds them for all dirs
|
||||
data = {"Hello": all_langs}
|
||||
for dk in check_translations.DYNAMIC_KEYS:
|
||||
data[dk] = all_langs
|
||||
trans_file.write_text(json.dumps(data))
|
||||
|
||||
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"
|
||||
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
|
||||
data = {"Hello": all_langs}
|
||||
for dk in check_translations.DYNAMIC_KEYS:
|
||||
data[dk] = all_langs
|
||||
trans_file.write_text(json.dumps(data))
|
||||
(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_polish_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("DEVX_LANG", "pl")
|
||||
import devx.i18n
|
||||
|
||||
importlib.reload(devx.i18n)
|
||||
result = devx.i18n._("Running tests...")
|
||||
assert "Uruchamianie testów" in result
|
||||
|
||||
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_includes_dynamic_keys(self, tmp_path: Path) -> None:
|
||||
"""Non-default source dirs should also 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 be present for all dirs
|
||||
assert "completed" in keys
|
||||
assert "pending" in keys
|
||||
|
||||
def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
|
||||
"""collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
|
||||
keys = check_translations.collect_keys(tmp_path)
|
||||
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)
|
||||
@@ -0,0 +1,866 @@
|
||||
"""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_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None:
|
||||
"""Project infrastructure patterns already in defaults are not duplicated."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
# .gitea/** should appear only once (deduplicated with defaults)
|
||||
assert config.infrastructure.count(".gitea/**") == 1
|
||||
assert "scripts/**" in config.infrastructure
|
||||
|
||||
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_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_skips_empty_tag(
|
||||
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
|
||||
) -> None:
|
||||
"""Tags with no matching files are skipped in default mode output."""
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"], "docs": ["docs/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Ansible files" in result.output
|
||||
# docs tag has no matching files — should not appear
|
||||
assert "Docs files" not 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
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--force with --github-output writes user-facing-changed=true and all tags true."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
assert "ansible-changed=true" in content
|
||||
assert "Forced user-facing-changed=true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_without_github_output_does_nothing(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""--force without --github-output falls through to normal classification."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--force", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "false"
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--force with --github-output and no tags writes only user-facing-changed=true."""
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={},
|
||||
)
|
||||
)
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
assert "ansible-changed" not in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
monkeypatch.setenv("FORCE_DEPLOY", "true")
|
||||
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
|
||||
assert "ansible-changed=true" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_deploy_env_var_false(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""FORCE_DEPLOY=false does not activate force mode."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
monkeypatch.setenv("FORCE_DEPLOY", "false")
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_flag_overrides_env_var(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""--force flag works even when FORCE_DEPLOY=false."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
monkeypatch.setenv("FORCE_DEPLOY", "false")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
@@ -0,0 +1,318 @@
|
||||
"""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_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
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Unit tests for config module constants."""
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
GITEA_API_URL,
|
||||
MAX_RETRIES,
|
||||
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_timeouts(self) -> None:
|
||||
assert DEFAULT_TIMEOUT == 30
|
||||
assert DEFAULT_PER_PAGE == 50
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestPyprojectReading:
|
||||
"""Test that config.py reads [tool.devx] from pyproject.toml."""
|
||||
|
||||
def test_pyproject_provides_values(self) -> None:
|
||||
"""When pyproject.toml has [tool.devx], values are read from it."""
|
||||
import devx.config as cfg
|
||||
|
||||
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 8
|
||||
assert cfg.REPO_OWNER == "oblachno-oss"
|
||||
|
||||
def test_env_overrides_pyproject(self, monkeypatch: object) -> None:
|
||||
"""Env vars take priority over pyproject.toml."""
|
||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM")
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "CUSTOM"
|
||||
assert cfg.TASK_ID_RE.search("CUSTOM-42")
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When no pyproject.toml exists, defaults are used."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("DEVX_REPO_OWNER", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||
assert cfg.REPO_OWNER == ""
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When pyproject.toml is invalid TOML, defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text("invalid toml {{{")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When pyproject.toml has no [tool.devx], defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 42
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""Env var override for int config takes priority over pyproject.toml."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 99
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When [tool] is not a dict, defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When [tool.devx] is not a dict, defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
importlib.reload(cfg)
|
||||
|
||||
|
||||
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 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")
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
importlib.reload(cfg)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""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"
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
|
||||
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
# Verify GiteaClient was constructed with parsed owner and repo (positional)
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
|
||||
assert call_args[0][3] == "infra" # repo is 4th positional arg
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
|
||||
clear=True,
|
||||
)
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner
|
||||
assert call_args[0][3] == "infra" # repo
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for devx.tools.create_pr."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.create_pr import (
|
||||
cli,
|
||||
create_pr,
|
||||
extract_task_id,
|
||||
find_existing_pr,
|
||||
get_repo_name,
|
||||
get_vikunja_task_title,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_valid(self) -> None:
|
||||
assert extract_task_id("DEVX-42-fix") == "DEVX-42"
|
||||
|
||||
def test_invalid(self) -> None:
|
||||
assert extract_task_id("feature") == ""
|
||||
|
||||
|
||||
class TestGetRepoName:
|
||||
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
|
||||
def test_from_env(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_from_github(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Repository name"):
|
||||
get_repo_name()
|
||||
|
||||
|
||||
class TestGetVikunjaTaskTitle:
|
||||
@patch("devx.tools.create_pr.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("DEVX-42") == "Add feature"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_token(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
|
||||
@patch("devx.tools.create_pr.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
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"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
|
||||
@patch("devx.tools.create_pr.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
|
||||
mock_client = MagicMock()
|
||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||
page2 = [{"identifier": "OTHER-99"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
|
||||
|
||||
class TestFindExistingPr:
|
||||
def test_found(self) -> None:
|
||||
client = MagicMock()
|
||||
client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}]
|
||||
result = find_existing_pr(client, "DEVX-42-fix")
|
||||
assert result is not None
|
||||
assert result["number"] == 10
|
||||
|
||||
def test_not_found(self) -> None:
|
||||
client = MagicMock()
|
||||
client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}]
|
||||
result = find_existing_pr(client, "DEVX-42-fix")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCreatePr:
|
||||
@patch("devx.tools.create_pr.GiteaClient")
|
||||
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
|
||||
mock_gitea.return_value = mock_client
|
||||
result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo")
|
||||
assert result["number"] == 15
|
||||
mock_client.create_pr.assert_called_once_with(
|
||||
title="DEVX-42: Add feature",
|
||||
head="DEVX-42-fix",
|
||||
base="master",
|
||||
body="body",
|
||||
)
|
||||
|
||||
@patch("devx.tools.create_pr.GiteaClient")
|
||||
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||
@patch("devx.tools.create_pr.find_existing_pr")
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
|
||||
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
|
||||
mock_client = MagicMock()
|
||||
mock_gitea.return_value = mock_client
|
||||
result = create_pr("DEVX-42-fix", "master", "", "owner", "repo")
|
||||
assert result["number"] == 10
|
||||
mock_client.create_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_repo_token(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
||||
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
def test_no_task_id_in_branch(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="does not contain a task ID"):
|
||||
create_pr("feature-branch", "master", "", "owner", "repo")
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.create_pr.create_pr")
|
||||
@patch("devx.tools.create_pr.subprocess.run")
|
||||
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||
def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
|
||||
mock_create.return_value = {"number": 1}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
|
||||
|
||||
@patch("devx.tools.create_pr.create_pr")
|
||||
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
|
||||
mock_create.return_value = {"number": 1}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.tools.create_pr.create_pr")
|
||||
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
|
||||
mock_create.return_value = {"number": 1}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
|
||||
assert result.exit_code == 0
|
||||
mock_create.assert_called_once()
|
||||
assert mock_create.call_args.args[2] == "PR body text"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_OWNER", "")
|
||||
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||
def test_missing_owner(self, mock_repo: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.create_pr.create_pr")
|
||||
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
|
||||
mock_create.return_value = {"number": 1}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
|
||||
assert result.exit_code == 0
|
||||
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo")
|
||||
|
||||
@patch("devx.tools.create_pr.subprocess.run")
|
||||
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||
def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Could not detect" in result.output
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Unit tests for devx.tools.create_task."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.create_task import cli
|
||||
|
||||
|
||||
class TestCreateTaskCli:
|
||||
@patch("devx.tools.create_task.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_success(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_task.return_value = {"identifier": "DEVX-60", "id": 60}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--title", "Add feature X"])
|
||||
assert result.exit_code == 0
|
||||
assert "DEVX-60" in result.output
|
||||
mock_client.create_task.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_token(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--title", "Add feature X"])
|
||||
assert result.exit_code != 0
|
||||
assert "VIKUNJA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.create_task.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_with_description(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_task.return_value = {"identifier": "DEVX-61", "id": 61}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--title", "Add feature Y", "--description", "<p>desc</p>"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client.create_task.call_args
|
||||
assert call_args.args[1] == "Add feature Y"
|
||||
assert call_args.args[2] == "<p>desc</p>"
|
||||
|
||||
@patch("devx.tools.create_task.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_description_from_stdin(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_task.return_value = {"identifier": "DEVX-62", "id": 62}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--title", "Add feature Z", "--description", "-"],
|
||||
input="<p>stdin desc</p>",
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client.create_task.assert_called_once()
|
||||
call_args = mock_client.create_task.call_args
|
||||
assert call_args.args[2] == "<p>stdin desc</p>"
|
||||
|
||||
@patch("devx.tools.create_task.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_custom_project_id(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_task.return_value = {"identifier": "GRM-10", "id": 10}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--title", "Task", "--project-id", "3"])
|
||||
assert result.exit_code == 0
|
||||
mock_client.create_task.assert_called_once_with(3, "Task", "")
|
||||
|
||||
@patch("devx.tools.create_task.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_identifier_in_response(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_task.return_value = {"id": 99}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--title", "Task"])
|
||||
assert result.exit_code == 0
|
||||
assert "id=99" in result.output
|
||||
@@ -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()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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
|
||||
|
||||
@patch("devx.ci.discover_runners.get_runner_count", return_value=2)
|
||||
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
|
||||
"""When --owner and --repo are provided, env vars are not used."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
|
||||
assert result.exit_code == 0
|
||||
mock_count.assert_called_once()
|
||||
# Verify owner/repo passed through
|
||||
args, kwargs = mock_count.call_args
|
||||
assert "myorg" in args
|
||||
assert "myrepo" in args
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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_github_env_multiline_uses_heredoc(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(6):
|
||||
(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()
|
||||
# Multi-line values must use heredoc syntax to avoid corrupting $GITHUB_ENV
|
||||
assert "ASSIGNED_FILES<<EOF" in content
|
||||
assert content.count("EOF") >= 2
|
||||
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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When a command decorator has no name and no following def, it is skipped."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
# The last @cli.command() has no explicit name and no def statement after it
|
||||
fake_cli.write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
||||
)
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||
assert "real_cmd" in commands
|
||||
assert "pass" not 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
|
||||