Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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: 2310 passing">
|
||||
<title>tests: 2310 passing</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="139" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="20" fill="#555"/>
|
||||
<rect x="45" width="94" height="20" fill="#4c1"/>
|
||||
<rect width="139" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="22" y="14">tests</text>
|
||||
<text x="92" y="14">2310 passing</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 909 B |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="118" height="20" role="img"
|
||||
aria-label="version: v0.50.0">
|
||||
<title>version: v0.50.0</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="118" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="59" height="20" fill="#555"/>
|
||||
<rect x="59" width="59" height="20" fill="#007ec6"/>
|
||||
<rect width="118" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="29" y="14">version</text>
|
||||
<text x="88" y="14">v0.50.0</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 903 B |
@@ -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,153 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-quality
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
make pytest-cov
|
||||
- name: Check unit test speed
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
- name: Translation completeness check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.check_translations
|
||||
- name: Dependency security scan
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
# Install pip in venv if missing (needed by pip-audit)
|
||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||
pip-audit --desc --skip-editable 2>&1 || true
|
||||
- name: Workflow dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Best-effort: only runs if act_runner is installed
|
||||
if command -v act_runner >/dev/null 2>&1; then
|
||||
make workflow-dryrun
|
||||
else
|
||||
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
|
||||
fi
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.classify_changes \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
--github-output
|
||||
|
||||
release-dry-run:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-release
|
||||
- name: Release dry-run validation
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run || true
|
||||
|
||||
pr-review:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Run automated PR review
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.pr_review \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
|
||||
auto-merge:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# from .taskid file, validates the PR title, and squash-merges.
|
||||
# No manual label or review needed — CI is the quality gate.
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
run: |
|
||||
python3 -m devx.ci.auto_merge \
|
||||
"$HEAD_REF" \
|
||||
"$PR_TITLE" \
|
||||
"$REPOSITORY" \
|
||||
"$PR_NUMBER"
|
||||
@@ -0,0 +1,245 @@
|
||||
name: Post-merge
|
||||
|
||||
# Runs on every push to master. A single workflow with conditional jobs
|
||||
# replaces separate workflows for release, wiki sync, badges, and
|
||||
# Vikunja task updates.
|
||||
#
|
||||
# Job dependency graph:
|
||||
#
|
||||
# detect-type ──┬── release (skip if release commit)
|
||||
# ├── badges (ALWAYS runs — even on release commits)
|
||||
# ├── configure-repo (independent — skip if release commit)
|
||||
# ├── sync-wiki (needs release — skip if release commit/fails)
|
||||
# └── vikunja (needs release — skip if release commit/fails)
|
||||
#
|
||||
# sync-wiki and vikunja depend on release succeeding so that the wiki
|
||||
# and task tracker are only updated when the code is actually released.
|
||||
# If release fails, they are skipped to avoid leaving the wiki or
|
||||
# Vikunja in an inconsistent state with the codebase on master.
|
||||
#
|
||||
# The badges job depends on release so it picks up the latest version
|
||||
# number. It uses `if: always()` with no is-release condition so it
|
||||
# runs on every push to master, including release commits. This
|
||||
# ensures badges (tests, coverage, version, etc.) are always current.
|
||||
#
|
||||
# When release creates a "release: vX.Y.Z" commit, the release
|
||||
# commit's post-merge run still updates badges (version badge picks
|
||||
# up the new version). Other jobs skip. The tag push triggers publish.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.ci.detect_release_commit
|
||||
|
||||
validate-commit-msg:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages click python-dotenv
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Validate latest commit message
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
git log -1 --format=%B > commit-msg.txt
|
||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
rm -f commit-msg.txt
|
||||
|
||||
release:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
run: make setup-release
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "devx-ci-bot"
|
||||
git config user.email "devx-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool tea
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type, release]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Sync documentation to wiki
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/sync-wiki" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
badges:
|
||||
needs: [detect-type, release]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Fetch latest master
|
||||
run: |
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.push_badges
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/badges" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type, release]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool tea
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/vikunja" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
configure-repo:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool tea
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/configure-repo" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Install CI tools
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||
- name: Configure tea login
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ github.ref_name }}" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Molecule
|
||||
.molecule/
|
||||
|
||||
# Badges
|
||||
.badges/
|
||||
@@ -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,306 @@
|
||||
# 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
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
```
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
- **Self-contained package** — `src/devx/` never imports from scripts outside the package
|
||||
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*` or `python -m devx.tools.*`
|
||||
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (NOT `.:src` since there are no scripts at repo root)
|
||||
- **Config via env vars** — `DEVX_*` environment variables with `.env` file fallback
|
||||
|
||||
## PR Workflow (Mandatory)
|
||||
|
||||
Every change to master goes through this workflow. No exceptions.
|
||||
|
||||
### Branch Protection (Required Gitea Settings)
|
||||
|
||||
Branch protection and labels are automatically configured by
|
||||
`python -m devx.tools.configure_repo`, which runs as a `configure-repo` job in
|
||||
the post-merge workflow on every push to master.
|
||||
|
||||
The following rules are enforced for `master`:
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
### 1. Create Vikunja Task
|
||||
Create a task in Vikunja to get a `DEVX-N` identifier.
|
||||
|
||||
### 2. Create Branch
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b DEVX-N-short-description
|
||||
```
|
||||
|
||||
### 3. Implement Changes
|
||||
- Write code following conventions below
|
||||
- Write/update tests (100% coverage required)
|
||||
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
|
||||
|
||||
### 4. Commit (Conventional Commits)
|
||||
Branch commits use conventional commit format (no `DEVX-N:` prefix):
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
```
|
||||
|
||||
### 5. Push and Create PR
|
||||
- **PR title format**: `DEVX-N: <vikunja task title>` (must match the Vikunja task title exactly)
|
||||
- PR body: summary of changes, `Closes DEVX-N`
|
||||
- Add `ready-to-merge` label **only after review is complete**
|
||||
|
||||
### 6. Review the PR
|
||||
|
||||
**Automated review (CI `pr-review` job):** Every PR triggers an automated
|
||||
review via `python -m devx.ci.pr_review`. This job posts a review with
|
||||
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found):
|
||||
|
||||
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
|
||||
- Best practices (no `print()`, no bare `except`, no `TODO`/`FIXME`,
|
||||
no functions > 50 lines)
|
||||
- Security (no hardcoded secrets, no `shell=True`, no `eval`/`exec`)
|
||||
- i18n (no raw strings in `click.echo()` without `_()` wrapper)
|
||||
- Resource management (no `open()` without `with`, no `Popen()` without cleanup)
|
||||
- Documentation (source changes must include doc updates)
|
||||
- Test coverage (source changes must include test updates)
|
||||
- Commit conventions (conventional commit format on PR commits)
|
||||
|
||||
The agent **must** address all `REQUEST_CHANGES` issues before proceeding.
|
||||
|
||||
### 7. Address Review Comments
|
||||
Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
|
||||
### 8. Approve and Merge
|
||||
Once all checklist items are verified and comments are addressed, approve
|
||||
the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
|
||||
2. **Check** that at least one substantive APPROVE review exists
|
||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
|
||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
||||
> workflow by adding the `ready-to-merge` label.
|
||||
|
||||
### Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the **post-merge workflow**
|
||||
(`.gitea/workflows/post-merge.yml`) runs automatically:
|
||||
|
||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||
release commits.
|
||||
|
||||
2. **release** — Runs `python -m devx.ci.release` which:
|
||||
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
|
||||
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
||||
- Updates `__version__` in `src/devx/__init__.py` (single source of truth)
|
||||
- Updates `CHANGELOG.md` with the new version section
|
||||
- Runs `make lint-ruff` and `make pytest-cov` to verify the release is healthy
|
||||
- Commits with `release: vX.Y.Z [skip ci]` prefix
|
||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||
- Pushes both the commit and tag to master
|
||||
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
|
||||
|
||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||
|
||||
5. **vikunja** — Marks the corresponding Vikunja task as done.
|
||||
|
||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
||||
which builds and publishes the package to the Gitea PyPI registry.
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require a new release. The project classifies changes
|
||||
using `python -m devx.ci.classify_changes`:
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/**` — Gitea Actions workflows
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md` — Project docs
|
||||
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml` — Config
|
||||
- `.env.example`, `.gitignore` — Config
|
||||
- `hooks/**` — Git hooks
|
||||
|
||||
**User-facing paths** (tool changes → release needed) — everything else:
|
||||
- `src/devx/**` — Python package source (except `__init__.py`)
|
||||
- `pyproject.toml` — Package metadata
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
## Script Separation and Import Rules
|
||||
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose | Release impact |
|
||||
|-----------|---------|----------------|
|
||||
| `src/devx/` | User-facing devx package | Changes trigger release |
|
||||
| `src/devx/ci/` | CI/CD automation (run by workflows) | Part of package |
|
||||
| `src/devx/tools/` | Developer tooling (run locally or by CI) | Part of package |
|
||||
| `tests/` | Test files | Workflow-only (no release) |
|
||||
|
||||
### Import Rules
|
||||
|
||||
1. **`src/devx/` is self-contained** — the package never imports from outside `src/`
|
||||
2. **CI modules** (`devx.ci.*`) may import from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, `devx.i18n`
|
||||
3. **Tool modules** (`devx.tools.*`) may import from `devx.api_clients`, `devx.config`, `devx.gitea_cli`
|
||||
4. **Cross-module imports** within `devx.ci.*` or `devx.tools.*` are allowed but must be documented
|
||||
|
||||
### PYTHONPATH Configuration
|
||||
|
||||
All workflows use `PYTHONPATH: src` — devx has no scripts at the repo root,
|
||||
so `.:src` is not needed. The `src` directory is the sole import root.
|
||||
|
||||
```yaml
|
||||
- name: Run script
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python -m devx.ci.example
|
||||
```
|
||||
|
||||
### tea CLI Integration
|
||||
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed
|
||||
by `python -m devx.tools.install_tools` and configured by
|
||||
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
|
||||
|
||||
**`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing:
|
||||
- `create_issue()` — Create issues with labels
|
||||
- `list_labels()` / `create_label()` / `add_label()` — Label management
|
||||
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
|
||||
- `create_release()` / `list_releases()` — Release management
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `DEVX-N <conventional commit>`. The
|
||||
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
|
||||
prefix before parsing. This ensures all merged work appears in the changelog.
|
||||
|
||||
### Version Bumping Rules (git-cliff)
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
The version source is `__version__` in `src/devx/__init__.py`, read by
|
||||
setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
|
||||
### Title Format Summary
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add release script` |
|
||||
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
||||
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
|
||||
|
||||
## Config System
|
||||
|
||||
devx uses environment variables with `.env` file fallback for configuration.
|
||||
|
||||
### DEVX_ Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
|
||||
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
|
||||
| `REPO_TOKEN` | (from .env) | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||
|
||||
### Per-Project Overrides
|
||||
|
||||
Projects using devx can override the default API URLs and language by setting
|
||||
`DEVX_*` environment variables or entries in their `.env` file. The config
|
||||
system loads `.env` automatically via `python-dotenv`.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||
- 100% test coverage required (`--cov-fail-under=100`)
|
||||
- Conventional commits on feature branches (no `DEVX-N:` prefix)
|
||||
- Branch names must include `DEVX-N` task ID
|
||||
- Line length: 120 chars
|
||||
- Secrets are passed via environment variables, never on the command line
|
||||
- All user-facing strings wrapped in `_()` for i18n
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
## [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,131 @@
|
||||
# devx — Reusable Development & CI/CD Tools
|
||||
|
||||
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects. devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package.
|
||||
|
||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Installation
|
||||
|
||||
Install from the Gitea PyPI registry:
|
||||
|
||||
```bash
|
||||
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
Or add the registry to your `pip.conf` / `pyproject.toml` and install normally:
|
||||
|
||||
```bash
|
||||
pip install devx
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### CI/CD Automation
|
||||
|
||||
devx provides CI/CD modules invoked via `python -m devx.ci.*`:
|
||||
|
||||
```bash
|
||||
# Release automation (versioning, changelog, tagging)
|
||||
python -m devx.ci.release
|
||||
python -m devx.ci.release --dry-run
|
||||
|
||||
# Publish a release to the Gitea PyPI registry
|
||||
python -m devx.ci.publish v1.0.0 oblachno-oss/devx
|
||||
|
||||
# Automated PR review
|
||||
python -m devx.ci.pr_review 42 oblachno-oss/devx
|
||||
|
||||
# Auto-merge a PR (validates title, squash-merges)
|
||||
python -m devx.ci.auto_merge feature-branch "DEVX-12: Add feature" oblachno-oss/devx 42
|
||||
|
||||
# Classify changes (user-facing vs workflow-only)
|
||||
python -m devx.ci.classify_changes --base origin/master --head HEAD --github-output
|
||||
|
||||
# Sync documentation to Gitea wiki
|
||||
python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
|
||||
|
||||
# Generate and push quality badges
|
||||
python -m devx.ci.push_badges
|
||||
|
||||
# Check translation completeness
|
||||
python -m devx.ci.check_translations
|
||||
|
||||
# Documentation coverage check
|
||||
python -m devx.ci.doc_coverage --fail-on-missing
|
||||
|
||||
# Validate a commit message
|
||||
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
|
||||
# Notify on CI failure (creates a Gitea issue)
|
||||
python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 --workflow ci --commit abc123
|
||||
```
|
||||
|
||||
### Developer Tools
|
||||
|
||||
devx provides developer tooling invoked via `python -m devx.tools.*`:
|
||||
|
||||
```bash
|
||||
# Set up a development environment (venv, deps, hooks)
|
||||
python -m devx.tools.setup --bin .venv/bin
|
||||
|
||||
# Install CI tools (actionlint, git-cliff, act_runner, tea)
|
||||
python -m devx.tools.install_tools
|
||||
python -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||
|
||||
# Check unit test speed
|
||||
python -m devx.tools.check_test_speed --max-seconds 10
|
||||
|
||||
# Configure repository (branch protection, labels)
|
||||
python -m devx.tools.configure_repo
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
devx also provides a `devx` CLI command:
|
||||
|
||||
```bash
|
||||
devx --help
|
||||
devx --version
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
devx reads configuration from environment variables with `.env` file fallback:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
|
||||
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
|
||||
| `DEVX_LANG` | `en` | Language (en, bg) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your tokens:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
|
||||
cd devx
|
||||
make setup # Create venv, install deps, hooks, CI tools
|
||||
make lint-all # ruff + pyright + bandit + actionlint
|
||||
make pytest-cov # Unit tests with 100% coverage
|
||||
```
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and architecture details.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
@@ -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,24 @@
|
||||
# devx — Reusable Development & CI/CD Tools
|
||||
|
||||
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects.
|
||||
|
||||
## Overview
|
||||
|
||||
devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package. It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) project to be reusable across all oblachno-oss projects.
|
||||
|
||||
## Installation
|
||||
|
||||
Install from the Gitea PyPI registry:
|
||||
|
||||
```bash
|
||||
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Core modules** — config, exceptions, i18n, api_clients, gitea_cli
|
||||
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review, classify_changes, etc.
|
||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed, configure_repo, generate_badges
|
||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible roles
|
||||
|
||||
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md) for full project conventions.
|
||||
@@ -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,50 @@
|
||||
# Architecture
|
||||
|
||||
devx is a reusable Python package providing development and CI/CD tools for oblachno-oss projects.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
src/devx/
|
||||
├── __init__.py # Version (single source of truth)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
├── config.py # Configuration system (DEVX_ env vars)
|
||||
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
|
||||
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
||||
├── i18n.py # Translation system (gettext-based, translations.json)
|
||||
├── exceptions.py # Custom exception types (DevxError, APIError)
|
||||
├── translations.json # Translation strings (en, bg, de, ru, zh)
|
||||
├── ci/ # CI/CD automation modules
|
||||
├── tools/ # Developer tooling modules
|
||||
└── molecule/ # Optional molecule testing helpers
|
||||
```
|
||||
|
||||
## Core Modules
|
||||
|
||||
### cli.py
|
||||
|
||||
Click-based CLI entry point. Provides three command groups: `devx ci`, `devx tools`, `devx molecule`. Each subcommand delegates to the corresponding module via `_run_module()`.
|
||||
|
||||
### i18n.py
|
||||
|
||||
Simple i18n system using a JSON translations file. Supports en, bg, de, ru, zh. Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a custom JSON file.
|
||||
|
||||
### exceptions.py
|
||||
|
||||
Custom exception hierarchy: `DevxError` (base), `APIError` (HTTP errors with status code and message).
|
||||
|
||||
### api_clients.py
|
||||
|
||||
HTTP API clients with connection pooling and retry logic:
|
||||
- `GiteaClient` — Gitea REST API (branch protection, labels, issues, PRs, releases, reviews)
|
||||
- `VikunjaClient` — Vikunja REST API (tasks, projects, comments)
|
||||
|
||||
Both clients retry on transient errors (429, 5xx, connection errors) with exponential backoff.
|
||||
|
||||
### config.py
|
||||
|
||||
Configuration constants with env-var overrides (`DEVX_` prefix). Includes API URLs, timeouts, retry settings, task prefix regex, and conventional commit regex.
|
||||
|
||||
### gitea_cli.py
|
||||
|
||||
Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for structured data. Used by CI scripts for Gitea API operations (issues, labels, PRs, releases, reviews).
|
||||
@@ -0,0 +1,85 @@
|
||||
# CI/CD Workflow
|
||||
|
||||
devx uses Gitea Actions for CI/CD automation. The workflow replicates GRM's automated pipeline but without molecule tests.
|
||||
|
||||
## Workflows
|
||||
|
||||
### CI (`ci.yml`)
|
||||
|
||||
Runs on pull requests. Jobs:
|
||||
|
||||
1. **quality** — lint (ruff, pyright, bandit, actionlint), unit tests with 100% coverage, test speed check, doc coverage, translation check, dependency scan
|
||||
2. **detect-changes** — classify changes as user-facing or workflow-only
|
||||
3. **release-dry-run** — dry-run the release script (only if user-facing changes)
|
||||
4. **pr-review** — automated PR review
|
||||
5. **auto-merge** — squash-merge PR when all checks pass
|
||||
|
||||
### Post-merge (`post-merge.yml`)
|
||||
|
||||
Runs on every push to master. Jobs:
|
||||
|
||||
1. **detect-type** — check if commit is a release commit
|
||||
2. **validate-commit-msg** — validate conventional commit format
|
||||
3. **release** — calculate next version, update changelog, tag, push
|
||||
4. **sync-wiki** — sync docs to Gitea wiki
|
||||
5. **badges** — generate and push quality badges
|
||||
6. **vikunja** — mark Vikunja task as done
|
||||
7. **configure-repo** — ensure branch protection and labels
|
||||
|
||||
### Publish (`publish.yml`)
|
||||
|
||||
Runs on tag pushes (`v*`). Builds the package, publishes to Gitea PyPI registry, and creates a Gitea release.
|
||||
|
||||
## CI Scripts
|
||||
|
||||
### auto_merge.py
|
||||
|
||||
Auto-merge PR when all CI checks pass. Reads task ID from `.taskid`, validates PR title format, checks Vikunja task exists, squash-merges with `DEVX-N <conventional commit>` title.
|
||||
|
||||
### release.py
|
||||
|
||||
Automated release using git-cliff. Calculates next semver version from conventional commits, updates `__version__` in `__init__.py`, updates `CHANGELOG.md`, runs lint and tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes.
|
||||
|
||||
### publish.py
|
||||
|
||||
Builds package with `python -m build`, publishes to Gitea PyPI registry via twine, creates Gitea release with git-cliff-generated notes.
|
||||
|
||||
### pr_review.py
|
||||
|
||||
Automated PR review. Checks architecture compliance, best practices, security, i18n, resource management, documentation, test coverage, and commit conventions. Posts inline comments and structured review.
|
||||
|
||||
### notify_failure.py
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses tea CLI for issue creation with failure labels.
|
||||
|
||||
### post_merge.py
|
||||
|
||||
Updates Vikunja task after a merge to master. Extracts task ID from commit message, marks task as done, posts a comment with the merge SHA.
|
||||
|
||||
### classify_changes.py
|
||||
|
||||
Classifies git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes. Patterns are configurable.
|
||||
|
||||
### discover_runners.py
|
||||
|
||||
Discovers available Gitea Actions runners at repo, org, and instance levels. Generates a dynamic matrix for parallel job distribution.
|
||||
|
||||
### detect_release_commit.py
|
||||
|
||||
Detects whether the latest git commit is a release commit. Writes `is-release=true` or `is-release=false` to GitHub output.
|
||||
|
||||
### push_badges.py
|
||||
|
||||
Generates SVG badge files from project metrics (tests, coverage, quality, version). Pushes to `badges` branch and updates README with cache-busting commit SHA URLs.
|
||||
|
||||
### distribute_molecule.py
|
||||
|
||||
Distributes molecule (scenario, platform) pairs across N parallel runners. Discovers scenarios under `ansible/roles/*/molecule/`.
|
||||
|
||||
### molecule_ci_guard.py
|
||||
|
||||
Runs molecule tests sequentially while polling Gitea for other runner failures. Aborts if another runner fails the same job.
|
||||
|
||||
### validate_commit_msg.py
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only (no `DEVX-N` prefix). On master: must have `DEVX-N` prefix from auto-merge.
|
||||
@@ -0,0 +1,105 @@
|
||||
# CLI Commands
|
||||
|
||||
devx provides a CLI with three command groups: `ci`, `tools`, and `molecule`.
|
||||
|
||||
## CI Commands
|
||||
|
||||
### `devx ci auto-merge`
|
||||
|
||||
Auto-merge a PR when all CI checks pass. Validates PR title, checks Vikunja task, squash-merges.
|
||||
|
||||
### `devx ci check-translations`
|
||||
|
||||
Check translation files for gaps, dead keys, and missing languages.
|
||||
|
||||
### `devx ci classify-changes`
|
||||
|
||||
Classify git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes.
|
||||
|
||||
### `devx ci detect-release-commit`
|
||||
|
||||
Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip ci]`).
|
||||
|
||||
### `devx ci discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for dynamic job distribution.
|
||||
|
||||
### `devx ci doc-coverage`
|
||||
|
||||
Check documentation coverage for CLI commands and major modules.
|
||||
|
||||
### `devx ci notify-failure`
|
||||
|
||||
Create a Gitea issue when a CI workflow fails.
|
||||
|
||||
### `devx ci post-merge`
|
||||
|
||||
Update Vikunja task after a merge to master.
|
||||
|
||||
### `devx ci pr-review`
|
||||
|
||||
Run automated PR review: check architecture compliance, best practices, and quality.
|
||||
|
||||
### `devx ci publish`
|
||||
|
||||
Build package, publish to Gitea PyPI registry, and create Gitea release.
|
||||
|
||||
### `devx ci push-badges`
|
||||
|
||||
Generate badge SVG files and push them to the `badges` branch.
|
||||
|
||||
### `devx ci release`
|
||||
|
||||
Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
### `devx ci sync-wiki`
|
||||
|
||||
Sync documentation from `docs/` to the Gitea wiki.
|
||||
|
||||
### `devx ci validate-commit-msg`
|
||||
|
||||
Validate commit messages for conventional commit format.
|
||||
|
||||
## Tools Commands
|
||||
|
||||
### `devx tools check-test-speed`
|
||||
|
||||
Run unit tests and enforce a maximum execution-time budget.
|
||||
|
||||
### `devx tools configure-repo`
|
||||
|
||||
Configure repository: branch protection + labels via Gitea API.
|
||||
|
||||
### `devx tools generate-badges`
|
||||
|
||||
Generate self-contained SVG badge files from project metrics.
|
||||
|
||||
### `devx tools install-checkmake`
|
||||
|
||||
Install checkmake (Makefile linter) if not already present.
|
||||
|
||||
### `devx tools install-tools`
|
||||
|
||||
Install CI/CD development tools: actionlint, git-cliff, act_runner, tea.
|
||||
|
||||
### `devx tools setup`
|
||||
|
||||
Project setup: install Python deps and pre-commit hooks.
|
||||
|
||||
## Molecule Commands
|
||||
|
||||
### `devx molecule distribute`
|
||||
|
||||
Distribute molecule test pairs across parallel runners.
|
||||
|
||||
### `devx molecule discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for molecule tests.
|
||||
|
||||
### `devx molecule guard`
|
||||
|
||||
Run molecule tests sequentially with CI failure polling.
|
||||
|
||||
### `devx molecule all`
|
||||
|
||||
Run all molecule scenarios on all supported OS platforms.
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-commit hook: fail if unit tests take longer than 10 seconds.
|
||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
||||
set -e
|
||||
export PYTHONPATH=src
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
@@ -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,126 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "devx"
|
||||
dynamic = ["version"]
|
||||
description = "Reusable development and CI/CD tools for oblachno-oss projects"
|
||||
readme = "README.md"
|
||||
license = {text = "GPL-3.0"}
|
||||
requires-python = ">=3.12"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.34.2",
|
||||
"python-dotenv>=1.2.2",
|
||||
"click>=8.4.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
devx = "devx.cli:cli"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "devx.__version__"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Minimal deps for CI scripts that only need click/dotenv/requests
|
||||
ci = [
|
||||
"pytest>=9.1.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
]
|
||||
# Lint and type-checking tools (quality job)
|
||||
lint = [
|
||||
"ruff>=0.15.17",
|
||||
"pyright>=1.1.410",
|
||||
"bandit>=1.8.2",
|
||||
"pip-audit>=2.10",
|
||||
"pre-commit>=4.6.0",
|
||||
]
|
||||
# Molecule testing (optional — for projects with Ansible roles)
|
||||
molecule = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"ansible>=14.0.0",
|
||||
]
|
||||
# Full dev environment (local development)
|
||||
dev = [
|
||||
"devx[ci,lint]",
|
||||
"build>=1.3.0",
|
||||
"twine>=6.2.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
devx = ["translations.json"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "--cov=src/devx --cov-report=term-missing --cov-fail-under=100"
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (not counted in coverage)",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["SIM117"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src"]
|
||||
pythonVersion = "3.12"
|
||||
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Change classification — determines which changes trigger a release
|
||||
# ---------------------------------------------------------------------------
|
||||
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
|
||||
# lint config, etc.) that applies to any Python project. We only specify
|
||||
# what's different about devx.
|
||||
#
|
||||
# Rule priority (first match wins):
|
||||
# 1. user_facing_overrides (safety — highest priority)
|
||||
# 2. infrastructure_overrides (explicit per-file)
|
||||
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||
# 4. Default: user-facing (safe)
|
||||
[tool.devx.classify]
|
||||
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
|
||||
|
||||
# Project-specific infrastructure paths (merged with defaults).
|
||||
# devx has no additional infrastructure paths — everything not in the
|
||||
# defaults is user-facing (src/devx/**, pyproject.toml, translations.json).
|
||||
infrastructure = []
|
||||
|
||||
# Infrastructure overrides — files that would default to user-facing
|
||||
# but are actually infrastructure:
|
||||
# - __init__.py: only contains __version__ (set by release.py, not user code)
|
||||
#
|
||||
# NOTE: api_clients.py is NOT here — it's used by devx's CI modules
|
||||
# (auto_merge.py, release.py, pr_review.py, etc.) which consumer projects
|
||||
# call via `python -m devx.ci.*`. Changes to api_clients.py affect consumer
|
||||
# projects' CI behavior, so it IS user-facing.
|
||||
infrastructure_overrides = [
|
||||
"src/devx/__init__.py",
|
||||
]
|
||||
|
||||
# User-facing overrides — safety override for broad infrastructure patterns
|
||||
# (empty — add when an infrastructure pattern is too broad)
|
||||
user_facing_overrides = []
|
||||
|
||||
# Tag patterns — additional categories for CI conditional execution
|
||||
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||
[tool.devx.classify.tags]
|
||||
# No tags needed for devx itself — it has no ansible/ directory
|
||||
@@ -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.4.1"
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Reusable HTTP API clients for Gitea and Vikunja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 Exception:
|
||||
message = str(e)
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Low-level Gitea REST API client with connection pooling."""
|
||||
|
||||
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._owner = owner
|
||||
self._repo = repo
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update repository settings (e.g. auto-delete branch after merge)."""
|
||||
r = self._request("PATCH", "", json=settings)
|
||||
return r.json()
|
||||
|
||||
# -- branch protection --
|
||||
|
||||
def list_branch_protections(self) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/branch_protections")
|
||||
return r.json()
|
||||
|
||||
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
r = self._request("POST", "/branch_protections", json=config)
|
||||
return r.json()
|
||||
|
||||
def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
r = self._request("PATCH", f"/branch_protections/{branch}", json=config)
|
||||
return r.json()
|
||||
|
||||
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Idempotent: create or update branch protection for the given branch."""
|
||||
existing = self.list_branch_protections()
|
||||
for p in existing:
|
||||
if p.get("branch_name") == branch:
|
||||
update_config = {k: v for k, v in config.items() if k != "branch_name"}
|
||||
return self.update_branch_protection(branch, update_config)
|
||||
return self.create_branch_protection(config)
|
||||
|
||||
# -- labels --
|
||||
|
||||
def list_labels(self) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/labels")
|
||||
return r.json()
|
||||
|
||||
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
|
||||
r = self._request(
|
||||
"POST",
|
||||
"/labels",
|
||||
json={"name": name, "color": color, "description": description},
|
||||
)
|
||||
return r.json()
|
||||
|
||||
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
|
||||
"""Idempotent: create label if it doesn't already exist."""
|
||||
labels = self.list_labels()
|
||||
for label in labels:
|
||||
if label["name"] == name:
|
||||
return None
|
||||
return self.create_label(name, color, description)
|
||||
|
||||
def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]:
|
||||
"""Create a new issue in the repository.
|
||||
|
||||
Args:
|
||||
labels: List of label IDs (integers, not names).
|
||||
"""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
r = self._request("POST", "/issues", json=payload)
|
||||
return r.json()
|
||||
|
||||
# -- pulls / releases --
|
||||
|
||||
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch labels currently attached to a pull request."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return r.json()
|
||||
|
||||
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
|
||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
Uses the combined status endpoint (/commits/{sha}/status) which
|
||||
returns one entry per context (the latest), deduplicated server-side.
|
||||
The plural endpoint (/commits/{sha}/statuses) returns every historical
|
||||
entry including stale "pending" ones that never got updated.
|
||||
"""
|
||||
r = self._request("GET", f"/commits/{sha}/status")
|
||||
data = r.json()
|
||||
return data.get("statuses", [])
|
||||
|
||||
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
|
||||
"""Fetch pull request details including mergeable state."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}")
|
||||
return r.json()
|
||||
|
||||
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the list of files changed in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
return r.json()
|
||||
|
||||
def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch reviews posted on a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/reviews")
|
||||
return r.json()
|
||||
|
||||
def create_review(
|
||||
self,
|
||||
pr_number: str | int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
comments: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
body: Top-level review body text.
|
||||
comments: Line-level comments with ``path``, ``body``,
|
||||
``new_position`` (and optionally ``old_position``).
|
||||
"""
|
||||
# Map common event names to Gitea API values
|
||||
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
|
||||
gitea_event = event_map.get(event, event)
|
||||
payload: dict[str, Any] = {"event": gitea_event, "body": body}
|
||||
if comments:
|
||||
payload["comments"] = comments
|
||||
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
|
||||
return r.json()
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"name": name or tag,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
"prerelease": prerelease,
|
||||
}
|
||||
r = self._request("POST", "/releases", json=payload)
|
||||
return r.json()
|
||||
|
||||
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
|
||||
"""Fetch a release by its tag name. Returns None if not found."""
|
||||
try:
|
||||
r = self._request("GET", f"/releases/tags/{tag}")
|
||||
return r.json()
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
def create_release_idempotent(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release, or return the existing one if it already exists.
|
||||
|
||||
This is idempotent — safe to call multiple times for the same tag.
|
||||
"""
|
||||
existing = self.get_release_by_tag(tag)
|
||||
if existing:
|
||||
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_task(self, task_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single task by its numeric ID."""
|
||||
r = self._request("GET", f"/tasks/{task_id}")
|
||||
return r.json()
|
||||
|
||||
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
|
||||
"""List tasks in a specific project (more efficient than listing all tasks)."""
|
||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def post_comment(self, task_id: int, comment: str) -> None:
|
||||
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
||||
|
||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-merge PR when all CI checks pass.
|
||||
|
||||
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
|
||||
(falling back to branch name extraction for backwards compatibility),
|
||||
validates the PR title, and squash-merges with a conventional commit
|
||||
message prefixed by the task ID.
|
||||
|
||||
PR title format: ``{PREFIX}-N: <vikunja task title>``
|
||||
Merge commit format: ``{PREFIX}-N <conventional commit message>``
|
||||
|
||||
The ``{PREFIX}`` is determined by ``DEVX_TASK_PREFIX`` (default: ``DEVX``).
|
||||
Each project sets its own prefix (e.g., ``GRM``, ``INFRA``).
|
||||
|
||||
The conventional commit message is extracted from the PR commits.
|
||||
This allows the PR title to be a human-friendly Vikunja task title
|
||||
while the squashed commit follows conventional commits.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
TASKID_FILE = ".taskid"
|
||||
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from .taskid file, falling back to branch name extraction.
|
||||
|
||||
The .taskid file is a simple text file containing just the task ID
|
||||
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
|
||||
branch name as a backwards-compatibility fallback.
|
||||
"""
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
task_id = path.read_text(encoding="utf-8").strip()
|
||||
if task_id:
|
||||
return task_id
|
||||
# Fallback: extract from branch name
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
"""Raise ClickException if PR title does not follow the required format.
|
||||
|
||||
Expected: ``{PREFIX}-N: <vikunja task title>``
|
||||
"""
|
||||
if not PR_TITLE_RE.match(pr_title):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n"
|
||||
" Expected: {task_id}: <task title>\n"
|
||||
" Got: {pr_title}",
|
||||
prefix=TASK_PREFIX,
|
||||
task_id=task_id,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
if not pr_title.startswith(f"{task_id}:"):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
task_id=task_id,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""Fetch the Vikunja task title for the given DEVX-N identifier.
|
||||
|
||||
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
|
||||
Raises ClickException if the token is set but the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return ""
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||
if matches:
|
||||
return str(matches[0].get("title", ""))
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
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.
|
||||
|
||||
Skips validation if VIKUNJA_TOKEN is not set (local dev).
|
||||
Raises ClickException if the task is not found or the title doesn't match.
|
||||
"""
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
if not vikunja_title:
|
||||
# VIKUNJA_TOKEN not set — skip validation (local dev)
|
||||
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
|
||||
return
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
expected=expected,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def 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."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
task_id = read_taskid(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
click.echo(_("Task ID: {task_id}", task_id=task_id))
|
||||
|
||||
validate_pr_title(pr_title, task_id)
|
||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
||||
|
||||
# Build merge title: DEVX-N: <conventional commit message>
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
conv_msg = extract_conventional_msg(commits)
|
||||
if not conv_msg:
|
||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
||||
merge_title = f"{task_id}: {conv_msg}"
|
||||
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — pull master and rebase, then retry
|
||||
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
|
||||
try:
|
||||
run_cmd(["git", "config", "user.name", "devx-ci-bot"])
|
||||
run_cmd(["git", "config", "user.email", "devx-ci-bot@oblachno.fyi"])
|
||||
run_cmd(["git", "fetch", "origin", "master"])
|
||||
run_cmd(["git", "rebase", "origin/master"])
|
||||
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
|
||||
click.echo(_("Rebased and pushed. Retrying merge..."))
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except (APIError, Exception) as retry_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
error=str(retry_err),
|
||||
)
|
||||
) from None
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed with HTTP {status}: {message}\n"
|
||||
"Please check the PR is ready and you have merge rights.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
pr_number=pr_number,
|
||||
merge_title=merge_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check translation files for gaps, dead keys, and missing languages.
|
||||
|
||||
Validates translation files against the Python source code that uses them.
|
||||
By default, checks ``src/devx/translations.json`` against keys used in
|
||||
``src/devx/**/*.py``. Additional translation sets can be checked by
|
||||
passing ``--translations`` flags (each pointing to a JSON file; the
|
||||
source directory is inferred as the parent of the translations file).
|
||||
|
||||
Checks performed (all fail with exit code 1 on error):
|
||||
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
|
||||
translations file.
|
||||
- **Dead keys**: a key in a translations file is not used in any code.
|
||||
- **Missing languages**: a key exists but is missing one of the 5 supported
|
||||
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.check_translations
|
||||
python3 -m devx.ci.check_translations --translations path/to/translations.json
|
||||
python3 -m devx.ci.check_translations --strict # warnings are errors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
|
||||
|
||||
# Default translation set: devx package itself
|
||||
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
||||
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||
|
||||
# Functions that wrap _() and receive a translation key as first arg.
|
||||
# Their string-literal arguments should be treated as translation keys.
|
||||
_I18N_WRAPPERS = {"_handle_errors"}
|
||||
|
||||
# Known dynamic keys used via _(variable) that can't be detected by AST.
|
||||
# These are status strings set as variable values and passed to _().
|
||||
DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationCheckResult:
|
||||
"""Result of a translation check for one translation set."""
|
||||
|
||||
name: str
|
||||
src_dir: Path
|
||||
trans_file: Path
|
||||
used_keys: set[str] = field(default_factory=set)
|
||||
defined_keys: set[str] = field(default_factory=set)
|
||||
missing_keys: set[str] = field(default_factory=set)
|
||||
dead_keys: set[str] = field(default_factory=set)
|
||||
missing_langs: dict[str, list[str]] = field(default_factory=dict)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_keys(filepath: Path) -> set[str]:
|
||||
"""Extract translation keys from a Python file using AST.
|
||||
|
||||
Detects:
|
||||
- ``_("key")`` calls with string-literal first argument
|
||||
- ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``)
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
|
||||
except SyntaxError:
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if (
|
||||
isinstance(func, ast.Name)
|
||||
and func.id in ("_", *_I18N_WRAPPERS)
|
||||
and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)
|
||||
):
|
||||
keys.add(node.args[0].value)
|
||||
return keys
|
||||
|
||||
|
||||
def collect_keys(src_dir: Path) -> set[str]:
|
||||
"""Collect all translation keys from .py files in a directory tree.
|
||||
|
||||
Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used
|
||||
via ``_(variable)`` and can't be detected by AST scanning.
|
||||
"""
|
||||
keys: set[str] = set()
|
||||
for pyfile in src_dir.rglob("*.py"):
|
||||
if pyfile.name == "i18n.py":
|
||||
continue
|
||||
keys |= extract_keys(pyfile)
|
||||
# Add dynamic keys for the default source directory
|
||||
if src_dir == DEFAULT_SRC_DIR:
|
||||
keys |= DYNAMIC_KEYS
|
||||
return keys
|
||||
|
||||
|
||||
def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult:
|
||||
"""Check one translation set for gaps and dead keys."""
|
||||
result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file)
|
||||
|
||||
# Collect used keys from source code
|
||||
result.used_keys = collect_keys(src_dir)
|
||||
|
||||
# Load defined keys from translations file
|
||||
if not trans_file.exists():
|
||||
result.errors.append(f"Translations file not found: {trans_file}")
|
||||
return result
|
||||
|
||||
translations = json.loads(trans_file.read_text(encoding="utf-8"))
|
||||
result.defined_keys = set(translations.keys())
|
||||
|
||||
# Check for missing keys (used in code but not in translations)
|
||||
result.missing_keys = result.used_keys - result.defined_keys
|
||||
for key in sorted(result.missing_keys):
|
||||
result.errors.append(f"Missing key in {name}: {key!r}")
|
||||
|
||||
# Check for dead keys (in translations but not used in code)
|
||||
result.dead_keys = result.defined_keys - result.used_keys
|
||||
for key in sorted(result.dead_keys):
|
||||
result.warnings.append(f"Dead key in {name}: {key!r}")
|
||||
|
||||
# Check for missing languages
|
||||
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.warnings.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.",
|
||||
)
|
||||
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
|
||||
def main(translations: tuple[Path, ...], strict: bool) -> 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
|
||||
has_warnings = False
|
||||
for result in results:
|
||||
print_result(result)
|
||||
if result.errors:
|
||||
has_errors = True
|
||||
if result.warnings:
|
||||
has_warnings = True
|
||||
|
||||
click.echo()
|
||||
if has_errors:
|
||||
click.echo("FAIL: Translation check found errors.", err=True)
|
||||
sys.exit(1)
|
||||
if strict and has_warnings:
|
||||
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
|
||||
sys.exit(1)
|
||||
if has_warnings:
|
||||
click.echo("PASS with warnings: Translation check passed (warnings present).")
|
||||
else:
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,727 @@
|
||||
#!/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.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",
|
||||
# Linting / formatting config
|
||||
".pre-commit-config.yaml",
|
||||
".ruff.toml",
|
||||
".ansible-lint",
|
||||
# Environment templates (not the actual .env which is gitignored)
|
||||
".env.example",
|
||||
# Git config
|
||||
".gitignore",
|
||||
# Project-level documentation (not part of the installed package)
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"TROUBLESHOOTING.md",
|
||||
# Generated venv activation scripts (created by `make setup`)
|
||||
"activate.sh",
|
||||
"activate.fish",
|
||||
"activate.zsh",
|
||||
# CI task tracking file (written by CI, not by developers)
|
||||
".taskid",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassifierConfig:
|
||||
"""Configuration for the change classifier.
|
||||
|
||||
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
|
||||
|
||||
By default, the framework's ``DEFAULT_INFRASTRUCTURE`` patterns are
|
||||
merged with the project's ``infrastructure`` list. Set
|
||||
``use_defaults = false`` to disable defaults and specify all
|
||||
patterns explicitly.
|
||||
|
||||
Attributes:
|
||||
infrastructure: Glob patterns for infrastructure paths
|
||||
(merged with DEFAULT_INFRASTRUCTURE unless use_defaults is False).
|
||||
infrastructure_overrides: Exact paths that are infrastructure
|
||||
despite not matching any infrastructure pattern.
|
||||
user_facing_overrides: Exact paths that are user-facing
|
||||
despite matching an infrastructure pattern (safety override).
|
||||
tags: Dict mapping tag name to list of glob patterns.
|
||||
use_defaults: If True (default), merge with DEFAULT_INFRASTRUCTURE.
|
||||
"""
|
||||
|
||||
infrastructure: list[str] = field(default_factory=list)
|
||||
infrastructure_overrides: list[str] = field(default_factory=list)
|
||||
user_facing_overrides: list[str] = field(default_factory=list)
|
||||
tags: dict[str, list[str]] = field(default_factory=dict)
|
||||
use_defaults: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
|
||||
"""Load classifier config from pyproject.toml.
|
||||
|
||||
Reads the ``[tool.devx.classify]`` section. If the section or
|
||||
file is missing, returns a config with only DEFAULT_INFRASTRUCTURE
|
||||
(everything else defaults to user-facing — safe-by-default).
|
||||
"""
|
||||
path = Path(pyproject_path)
|
||||
if not path.exists():
|
||||
return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE))
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data: dict[str, Any] = tomllib.load(f)
|
||||
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
|
||||
|
||||
use_defaults = classify_cfg.get("use_defaults", True)
|
||||
project_infra = list(classify_cfg.get("infrastructure", []))
|
||||
|
||||
if use_defaults:
|
||||
# Merge defaults with project-specific patterns (deduplicated)
|
||||
merged = list(DEFAULT_INFRASTRUCTURE)
|
||||
for p in project_infra:
|
||||
if p not in merged:
|
||||
merged.append(p)
|
||||
infrastructure = merged
|
||||
else:
|
||||
infrastructure = project_infra
|
||||
|
||||
return cls(
|
||||
infrastructure=infrastructure,
|
||||
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
|
||||
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
|
||||
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
|
||||
use_defaults=use_defaults,
|
||||
)
|
||||
|
||||
|
||||
class ChangeClassifier:
|
||||
"""Classify changed files as user-facing or infrastructure.
|
||||
|
||||
Uses layered rules with safe-by-default semantics.
|
||||
|
||||
Rule evaluation order (first match wins):
|
||||
1. User-facing overrides (safety — highest priority)
|
||||
2. Infrastructure overrides
|
||||
3. Infrastructure patterns
|
||||
4. Default: user-facing (safe)
|
||||
"""
|
||||
|
||||
def __init__(self, config: ClassifierConfig | None = None) -> None:
|
||||
self.config = config or ClassifierConfig.from_pyproject()
|
||||
# Pre-compile infrastructure patterns for efficiency
|
||||
self._infra_patterns = list(self.config.infrastructure)
|
||||
self._infra_overrides = set(self.config.infrastructure_overrides)
|
||||
self._user_overrides = set(self.config.user_facing_overrides)
|
||||
|
||||
def classify_file(self, file_path: str) -> FileClassification:
|
||||
"""Classify a single file path.
|
||||
|
||||
Returns a FileClassification with the decision and reason.
|
||||
"""
|
||||
tags = self._compute_tags(file_path)
|
||||
|
||||
# 1. User-facing overrides (highest priority — safety)
|
||||
if file_path in self._user_overrides:
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason="User-facing override (safety override)",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# 2. Infrastructure overrides
|
||||
if file_path in self._infra_overrides:
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=False,
|
||||
reason="Infrastructure override (explicitly listed)",
|
||||
matched_rule="infrastructure_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# 3. Infrastructure patterns
|
||||
for pattern in self._infra_patterns:
|
||||
if _matches_glob(file_path, pattern):
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=False,
|
||||
reason=f"Infrastructure (matches '{pattern}')",
|
||||
matched_rule=f"infrastructure: {pattern}",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# 4. Default: user-facing (safe-by-default)
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason="User-facing (default — not in infrastructure patterns)",
|
||||
matched_rule=None,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def classify(self, files: list[str]) -> ClassificationResult:
|
||||
"""Classify a list of changed files.
|
||||
|
||||
Returns a ClassificationResult with per-file details and
|
||||
aggregated lists.
|
||||
"""
|
||||
result = ClassificationResult()
|
||||
all_tags: dict[str, list[str]] = {}
|
||||
|
||||
for f in files:
|
||||
fc = self.classify_file(f)
|
||||
result.files.append(fc)
|
||||
if fc.is_user_facing:
|
||||
result.user_facing.append(f)
|
||||
else:
|
||||
result.infrastructure.append(f)
|
||||
for tag in fc.tags:
|
||||
all_tags.setdefault(tag, []).append(f)
|
||||
|
||||
result.tags = all_tags
|
||||
return result
|
||||
|
||||
def _compute_tags(self, file_path: str) -> frozenset[str]:
|
||||
"""Compute custom category tags for a file path."""
|
||||
matched: set[str] = set()
|
||||
for tag_name, patterns in self.config.tags.items():
|
||||
for pattern in patterns:
|
||||
if _matches_glob(file_path, pattern):
|
||||
matched.add(tag_name)
|
||||
break
|
||||
return frozenset(matched)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_git(args: list[str]) -> str:
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip())
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_changed_files(base: str, head: str) -> list[str]:
|
||||
"""Get list of files changed between base and head refs."""
|
||||
output = run_git(["git", "diff", "--name-only", base, head])
|
||||
if not output:
|
||||
return []
|
||||
return output.split("\n")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatible API (used by release.py and CI workflows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Singleton classifier — loaded lazily from pyproject.toml
|
||||
_classifier: ChangeClassifier | None = None
|
||||
|
||||
|
||||
def _get_classifier() -> ChangeClassifier:
|
||||
"""Get or create the singleton classifier from pyproject.toml."""
|
||||
global _classifier # noqa: PLW0603
|
||||
if _classifier is None:
|
||||
_classifier = ChangeClassifier()
|
||||
return _classifier
|
||||
|
||||
|
||||
def is_workflow_only(
|
||||
file_path: str,
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if a file path is infrastructure (not user-facing).
|
||||
|
||||
Backward-compatible API. Prefer ``ChangeClassifier.classify_file()``
|
||||
for new code.
|
||||
|
||||
Args:
|
||||
file_path: Path relative to repo root.
|
||||
patterns: Deprecated. If provided, uses simple prefix matching
|
||||
against these patterns instead of the config-driven classifier.
|
||||
"""
|
||||
if patterns is not None:
|
||||
# Legacy mode — simple prefix matching
|
||||
return any(file_path.startswith(p) or file_path == p for p in patterns)
|
||||
return not _get_classifier().classify_file(file_path).is_user_facing
|
||||
|
||||
|
||||
def is_user_facing(
|
||||
file_path: str,
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if a file path is user-facing (affects the released package).
|
||||
|
||||
Inverse of ``is_workflow_only()``.
|
||||
"""
|
||||
return not is_workflow_only(file_path, patterns)
|
||||
|
||||
|
||||
def classify_changes(
|
||||
files: list[str],
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Classify changed files into user-facing and workflow-only.
|
||||
|
||||
Returns a dict with keys "user_facing" and "workflow_only".
|
||||
"""
|
||||
if patterns is not None:
|
||||
# Legacy mode
|
||||
user_facing: list[str] = []
|
||||
workflow_only: list[str] = []
|
||||
for f in files:
|
||||
if is_user_facing(f, patterns):
|
||||
user_facing.append(f)
|
||||
else:
|
||||
workflow_only.append(f)
|
||||
return {"user_facing": user_facing, "workflow_only": workflow_only}
|
||||
|
||||
result = _get_classifier().classify(files)
|
||||
return {"user_facing": result.user_facing, "workflow_only": result.infrastructure}
|
||||
|
||||
|
||||
def has_user_facing_changes(
|
||||
base: str,
|
||||
head: str,
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if any user-facing files changed between base and head.
|
||||
|
||||
Imported by ``devx.ci.release`` to decide whether a release is needed.
|
||||
"""
|
||||
files = get_changed_files(base, head)
|
||||
if patterns is not None:
|
||||
return any(is_user_facing(f, patterns) for f in files)
|
||||
return _get_classifier().classify(files).has_user_facing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea Actions output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
||||
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
|
||||
@click.option(
|
||||
"--check",
|
||||
default="all",
|
||||
help="Check specific category: 'all' (default), 'user-facing', or any tag name "
|
||||
"defined in [tool.devx.classify.tags] (e.g., 'ansible').",
|
||||
)
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
|
||||
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
||||
"""Classify git changes and output results."""
|
||||
classifier = _get_classifier()
|
||||
available_tags = list(classifier.config.tags.keys())
|
||||
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("No tags found — treating all changes as user-facing.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("true")
|
||||
else:
|
||||
click.echo(_("No tags found — treating all changes as user-facing."))
|
||||
return
|
||||
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "false")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "false")
|
||||
click.echo(f"No changes between {base} and {head}.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("false")
|
||||
else:
|
||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||
return
|
||||
|
||||
result = classifier.classify(files)
|
||||
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||
click.echo(f"User-facing files changed: {result.has_user_facing}")
|
||||
for tag in available_tags:
|
||||
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
|
||||
return
|
||||
|
||||
# --check: check a specific tag or user-facing
|
||||
if check != "all":
|
||||
if check == "user-facing":
|
||||
checked_files = result.user_facing
|
||||
has_checked = bool(checked_files)
|
||||
label = "User-facing"
|
||||
elif check in available_tags:
|
||||
checked_files = result.tags.get(check, [])
|
||||
has_checked = bool(checked_files)
|
||||
label = check.capitalize()
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
check=check,
|
||||
tags=", " + ", ".join(available_tags) if available_tags else "",
|
||||
)
|
||||
)
|
||||
if quiet:
|
||||
click.echo("true" if has_checked else "false")
|
||||
return
|
||||
click.echo(_("\n{label} files changed ({count}):", label=label, count=len(checked_files)))
|
||||
for f in checked_files:
|
||||
click.echo(f" {f}")
|
||||
click.echo(
|
||||
_("\nResult: {status}", status=f"{label} changes detected" if has_checked else f"No {label} changes")
|
||||
)
|
||||
return
|
||||
|
||||
has_user = result.has_user_facing
|
||||
|
||||
if quiet:
|
||||
click.echo("true" if has_user else "false")
|
||||
return
|
||||
|
||||
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
|
||||
click.echo(_("\nUser-facing changes ({count}):", count=len(result.user_facing)))
|
||||
for f in result.user_facing:
|
||||
click.echo(f" {f}")
|
||||
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure)))
|
||||
for f in result.infrastructure:
|
||||
click.echo(f" {f}")
|
||||
for tag in available_tags:
|
||||
tag_files = result.tags.get(tag, [])
|
||||
if tag_files:
|
||||
click.echo(_("\n{tag} files ({count}):", tag=tag.capitalize(), count=len(tag_files)))
|
||||
for f in tag_files:
|
||||
click.echo(f" {f}")
|
||||
if has_user:
|
||||
status = "USER-FACING changes detected — release needed"
|
||||
else:
|
||||
status = "Workflow-only changes — no release needed"
|
||||
click.echo(_("\nResult: {status}", status=status))
|
||||
|
||||
if not has_user:
|
||||
sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether the latest git commit is a release commit.
|
||||
|
||||
Release commits have the format ``release: vX.Y.Z [skip ci]``.
|
||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Get the subject of the latest git commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def is_release_commit(message: str) -> bool:
|
||||
"""Check if a commit message matches the release commit format."""
|
||||
return bool(RELEASE_RE.match(message))
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
msg = get_commit_message()
|
||||
click.echo(f"Commit message: {msg}")
|
||||
is_release = is_release_commit(msg)
|
||||
write_github_output("is-release", "true" if is_release else "false")
|
||||
if is_release:
|
||||
click.echo("Release commit — skipping all post-merge jobs.")
|
||||
else:
|
||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -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.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.
|
||||
"""
|
||||
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,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,87 @@
|
||||
#!/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>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
# Check if "bug" label exists
|
||||
labels: list[str] = []
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
labels = ["bug"]
|
||||
|
||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
tea.add_label(repo, issue["index"], labels)
|
||||
return int(issue.get("index", 0))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", required=True, help="Repository in owner/name format.")
|
||||
@click.option("--run-id", required=True, help="CI run ID.")
|
||||
@click.option("--workflow", required=True, help="Workflow name.")
|
||||
@click.option("--commit", required=True, help="Commit SHA.")
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
f"- **Run ID**: #{run_id}\n"
|
||||
f"- **Commit**: `{commit[:8]}`\n"
|
||||
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
|
||||
f"{repo}/actions/runs/{run_id}\n\n"
|
||||
f"Please investigate and fix the issue."
|
||||
)
|
||||
|
||||
try:
|
||||
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,197 @@
|
||||
#!/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, 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 — warn but don't fail
|
||||
click.echo(
|
||||
_(
|
||||
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
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 is a project management tool — if it's down, the merge
|
||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
||||
click.echo(
|
||||
_(
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
||||
"please update the Vikunja task manually.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
task_id=task_id,
|
||||
vikunja_id=vikunja_task_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -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,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build package, optionally publish to PyPI or Gitea PyPI, and create Gitea release.
|
||||
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
Uses the ``tea`` Gitea CLI for release creation.
|
||||
|
||||
Publishing destinations (checked in order):
|
||||
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
is converted to a packages URL). Uses ``twine upload
|
||||
--repository-url <url> -u <token> -p <token>`` with the
|
||||
``REPO_TOKEN`` as both username and password.
|
||||
2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard
|
||||
``twine upload -u __token__ -p <token>`` flow.
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def generate_release_notes(tag: str) -> str:
|
||||
"""Generate release notes for the given tag using git-cliff.
|
||||
|
||||
Falls back to a generic message if git-cliff is not available.
|
||||
"""
|
||||
cliff_bin = shutil.which("git-cliff")
|
||||
if not cliff_bin:
|
||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
||||
|
||||
|
||||
def build_package() -> None:
|
||||
"""Build the Python package using python -m build."""
|
||||
result = subprocess.run( # nosec B603
|
||||
[sys.executable, "-m", "build"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Package build failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def publish_to_pypi(token: str) -> None:
|
||||
"""Publish built packages to PyPI using twine."""
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"twine",
|
||||
"upload",
|
||||
"dist/*",
|
||||
"-u",
|
||||
"__token__",
|
||||
"-p",
|
||||
token,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PyPI publish failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Published to PyPI."))
|
||||
|
||||
|
||||
def publish_to_gitea_registry(registry_url: str, token: str) -> None:
|
||||
"""Publish built packages to a Gitea PyPI registry using twine.
|
||||
|
||||
Uses the token as both username and password, which is the standard
|
||||
Gitea package authentication method.
|
||||
"""
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"twine",
|
||||
"upload",
|
||||
"dist/*",
|
||||
"--repository-url",
|
||||
registry_url,
|
||||
"-u",
|
||||
token,
|
||||
"-p",
|
||||
token,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Published to Gitea PyPI registry."))
|
||||
|
||||
|
||||
def _default_gitea_registry_url() -> str:
|
||||
"""Derive a Gitea PyPI registry URL from GITEA_API_URL.
|
||||
|
||||
Converts e.g. ``https://git.example.com/api/v1`` to
|
||||
``https://git.example.com/api/packages/<owner>/pypi``.
|
||||
The owner is read from ``DEVX_REPO_OWNER``.
|
||||
"""
|
||||
base = GITEA_API_URL.rstrip("/")
|
||||
# Strip /api/v1 or /api suffix to get the base URL
|
||||
if base.endswith("/api/v1"):
|
||||
base = base[: -len("/api/v1")]
|
||||
elif base.endswith("/api"):
|
||||
base = base[: -len("/api")]
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
return f"{base}/api/packages/{owner}/pypi"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("tag")
|
||||
@click.argument("repo")
|
||||
@click.option(
|
||||
"--registry-url",
|
||||
default=None,
|
||||
help="Gitea PyPI registry URL. Defaults to DEVX_PYPI_REGISTRY_URL env var "
|
||||
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
|
||||
"instead of standard PyPI (unless PYPI_TOKEN is also set).",
|
||||
)
|
||||
def main(tag: str, repo: str, registry_url: str | None) -> None:
|
||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
pypi_token = os.environ.get("PYPI_TOKEN", "")
|
||||
|
||||
# Resolve registry URL: CLI flag > env var > derived from GITEA_API_URL
|
||||
if registry_url is None:
|
||||
registry_url = os.environ.get("DEVX_PYPI_REGISTRY_URL", "")
|
||||
if not registry_url:
|
||||
registry_url = _default_gitea_registry_url()
|
||||
|
||||
build_package()
|
||||
|
||||
if pypi_token:
|
||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||
publish_to_pypi(pypi_token)
|
||||
elif registry_url:
|
||||
# Gitea PyPI registry flow
|
||||
publish_to_gitea_registry(registry_url, gitea_token)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"PYPI_TOKEN not set and no registry URL configured — "
|
||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
||||
)
|
||||
)
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Gitea release {tag} created.",
|
||||
tag=tag,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/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 re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
# Badge filenames that get pushed to the badges branch
|
||||
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
|
||||
|
||||
# Files that contain badge URLs and need to be updated
|
||||
FILES_WITH_BADGE_URLS = ["README.md", "docs/index.md"]
|
||||
|
||||
# Pattern to match raw/branch/badges/<name>.svg URLs
|
||||
_BADGE_URL_RE = re.compile(r"(https://[^/]+/[^/]+/[^/]+/raw/)(?:branch/badges|commit/[0-9a-f]{40})/([a-z_]+\.svg)")
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
|
||||
|
||||
|
||||
def _run_capture(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and capture stdout."""
|
||||
return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs) # nosec B603
|
||||
|
||||
|
||||
def fetch_latest_master(branch: str = "master") -> None:
|
||||
"""Fetch and hard-reset to the latest remote branch.
|
||||
|
||||
Ensures the working tree reflects the absolute latest state of the
|
||||
remote, which is critical when the release job may have just pushed
|
||||
a new version commit.
|
||||
"""
|
||||
_run(["git", "fetch", "origin", branch]) # nosec B607
|
||||
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
||||
click.echo(f"Synced to latest origin/{branch}")
|
||||
|
||||
|
||||
def generate_badges(output_dir: str) -> None:
|
||||
"""Generate badge SVG files using devx.tools.generate_badges."""
|
||||
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
|
||||
badges = list(Path(output_dir).glob("*.svg"))
|
||||
if not badges:
|
||||
raise click.ClickException("No badge SVG files generated")
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
|
||||
|
||||
def push_to_badges_branch(badges_dir: str) -> str:
|
||||
"""Push generated badges to the orphan ``badges`` branch.
|
||||
|
||||
Returns the commit SHA of the pushed badges branch.
|
||||
"""
|
||||
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
|
||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
|
||||
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
|
||||
_run(["git", "rm", "-rf", "."]) # nosec B607
|
||||
|
||||
# Copy badge files to root
|
||||
import shutil
|
||||
|
||||
for svg in Path(badges_dir).glob("*.svg"):
|
||||
shutil.copy2(svg, Path.cwd() / svg.name)
|
||||
|
||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
||||
click.echo("Badges pushed to badges branch")
|
||||
|
||||
# Get the commit SHA of the badges branch
|
||||
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
||||
sha = result.stdout.strip()
|
||||
click.echo(f"Badges commit SHA: {sha}")
|
||||
return sha
|
||||
|
||||
|
||||
def update_badge_urls(content: str, badges_sha: str) -> str:
|
||||
"""Replace raw/branch/badges/<name>.svg URLs with raw/commit/<sha>/<name>.svg.
|
||||
|
||||
This bypasses Gitea's 6-hour cache on raw/branch/ URLs by using a
|
||||
URL that changes each time the badges branch is updated.
|
||||
"""
|
||||
return _BADGE_URL_RE.sub(
|
||||
lambda m: f"{m.group(1)}commit/{badges_sha}/{m.group(2)}",
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) -> None:
|
||||
"""Update README.md and docs/index.md with cache-busting badge URLs.
|
||||
|
||||
Switches back to master, replaces ``raw/branch/badges/`` URLs with
|
||||
``raw/commit/<sha>/`` URLs, commits and pushes.
|
||||
"""
|
||||
root = repo_root or REPO_ROOT
|
||||
|
||||
# Switch back to master
|
||||
_run(["git", "checkout", "master"]) # nosec B607
|
||||
_run(["git", "fetch", "origin", "master"]) # nosec B607
|
||||
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
|
||||
|
||||
updated_any = False
|
||||
for filename in FILES_WITH_BADGE_URLS:
|
||||
filepath = root / filename
|
||||
if not filepath.exists():
|
||||
continue
|
||||
content = filepath.read_text()
|
||||
new_content = update_badge_urls(content, badges_sha)
|
||||
if new_content != content:
|
||||
filepath.write_text(new_content)
|
||||
click.echo(f"Updated badge URLs in {filename}")
|
||||
updated_any = True
|
||||
|
||||
if not updated_any:
|
||||
click.echo("No badge URLs found to update — README already up to date")
|
||||
return
|
||||
|
||||
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
|
||||
_run(
|
||||
[
|
||||
"git",
|
||||
"commit",
|
||||
"--no-verify",
|
||||
"-m",
|
||||
f"chore: update badge URLs to commit {badges_sha[:8]} [skip ci]",
|
||||
]
|
||||
) # nosec B607
|
||||
_run(["git", "push", "origin", "master"]) # nosec B607
|
||||
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
|
||||
@click.option("--branch", default="master", help="Branch to sync before generating badges.")
|
||||
@click.option(
|
||||
"--no-readme-update",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip updating README with cache-busting URLs (for local testing).",
|
||||
)
|
||||
def main(output_dir: str, branch: str, no_readme_update: bool) -> None:
|
||||
"""Generate badges and push them to the badges branch."""
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
Uses git-cliff to determine the next semver version from conventional commits
|
||||
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
|
||||
source of truth, read by setuptools via ``dynamic = ["version"]``) and
|
||||
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
|
||||
with the changelog as the tag message, and pushes both to trigger the publish
|
||||
workflow.
|
||||
|
||||
**Test enforcement**: Before committing or tagging, the script runs
|
||||
``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy.
|
||||
If either fails, the release is aborted — no commit, no tag. This ensures
|
||||
we never release a version that fails tests. Use ``--skip-tests`` only for
|
||||
emergency releases (not recommended).
|
||||
|
||||
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
|
||||
clean while still being descriptive. Loops are prevented by the
|
||||
``has_unreleased_changes`` check — after a release commit is tagged, the next
|
||||
run finds no unreleased changes and exits.
|
||||
|
||||
This script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits with a message and does nothing. If the tag already exists
|
||||
(e.g., from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
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 get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""Check if a git tag already exists."""
|
||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_bumped_version() -> str:
|
||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
||||
version = result.stdout.strip()
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# git-cliff may return with or without 'v' prefix
|
||||
return version.lstrip("v")
|
||||
|
||||
|
||||
def get_changelog(new_version: str) -> str:
|
||||
"""Generate changelog content for the new version using git-cliff."""
|
||||
result = run_cmd(
|
||||
[
|
||||
"git-cliff",
|
||||
"--config",
|
||||
CLIFF_CONFIG,
|
||||
"--tag",
|
||||
f"v{new_version}",
|
||||
"--unreleased",
|
||||
"--bump",
|
||||
]
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
"""Check if there are unreleased conventional commits since the last tag.
|
||||
|
||||
Uses ``git log`` to check for commits between the last tag and HEAD.
|
||||
This is more reliable than comparing version strings — if git-cliff
|
||||
bumps to the same version (e.g., two fix commits between tags), the
|
||||
version comparison would incorrectly report "no unreleased changes"
|
||||
even though there are commits that haven't been released yet.
|
||||
"""
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
# Check for any commits since the last tag
|
||||
result = run_cmd(
|
||||
["git", "log", f"{latest}..HEAD", "--oneline"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
"""Update __version__ in __init__.py."""
|
||||
with open(INIT_FILE) as f:
|
||||
content = f.read()
|
||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||
updated = re.sub(
|
||||
r'^__version__\s*=\s*"[^"]*"',
|
||||
f'__version__ = "{new_version}"',
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
with open(INIT_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def update_changelog(changelog: str) -> None:
|
||||
"""Prepend the new changelog section to CHANGELOG.md.
|
||||
|
||||
The changelog from git-cliff may include a header (e.g., "# Changelog").
|
||||
This function strips everything before the first ``## [`` version section
|
||||
before inserting, to avoid duplicating the header.
|
||||
"""
|
||||
# Strip git-cliff header — keep only from the first version section
|
||||
section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE)
|
||||
if section_match:
|
||||
changelog = changelog[section_match.start() :]
|
||||
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
existing = f.read()
|
||||
except FileNotFoundError:
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
f.write(changelog + "\n")
|
||||
return
|
||||
|
||||
# Find the first version section header (## [...] or ## [unreleased])
|
||||
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
|
||||
if match:
|
||||
# Insert before the first version section
|
||||
pos = match.start()
|
||||
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
|
||||
else:
|
||||
# No version sections found — append
|
||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def commit_release_changes(new_version: str) -> bool:
|
||||
"""Stage version file and changelog, then create a release commit.
|
||||
|
||||
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
|
||||
The commit is created with ``--no-verify`` to bypass the commit-msg hook
|
||||
(which requires ``DEVX-N:`` prefix for master commits) since release
|
||||
commits are a special case generated by the release script.
|
||||
Returns True if a commit was created, False if there were no staged changes.
|
||||
"""
|
||||
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
|
||||
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
return True
|
||||
|
||||
|
||||
def run_tests() -> None:
|
||||
"""Run lint and tests to verify the release is healthy.
|
||||
|
||||
This is called *after* version files are updated but *before* the tag is
|
||||
created, ensuring we never tag a release that fails tests.
|
||||
"""
|
||||
click.echo(_("Running lint checks..."))
|
||||
lint = run_cmd(["make", "lint-ruff"], check=False)
|
||||
if lint.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Lint passed."))
|
||||
|
||||
click.echo(_("Running tests..."))
|
||||
tests = run_cmd(["make", "pytest-cov"], check=False)
|
||||
if tests.returncode != 0:
|
||||
# Print test output for debugging (capture=True hides it otherwise)
|
||||
if tests.stdout:
|
||||
click.echo(tests.stdout)
|
||||
if tests.stderr:
|
||||
click.echo(tests.stderr, err=True)
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Tests passed."))
|
||||
|
||||
|
||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
|
||||
"""Create an annotated tag with the changelog as message and push it.
|
||||
|
||||
Returns True if the tag was created/pushed, False if it already existed.
|
||||
"""
|
||||
tag = f"v{new_version}"
|
||||
if tag_exists(tag):
|
||||
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", tag])
|
||||
return True
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option(
|
||||
"--skip-tests",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master" and not dry_run:
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
if branch != "master" and dry_run:
|
||||
click.echo(
|
||||
_(
|
||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
|
||||
# Release lock: if HEAD is already a release commit, check if the tag
|
||||
# exists. If the tag is missing (e.g., tag push failed in a previous run),
|
||||
# create and push it instead of skipping — this recovers from the
|
||||
# common failure mode where the commit was pushed but the tag was not.
|
||||
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):
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}') and tag {tag} exists. 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:
|
||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
||||
|
||||
if dry_run:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would push commit to master"))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
|
||||
# Update version file
|
||||
update_init_version(new_version)
|
||||
click.echo(_("Updated version in {init}", init=INIT_FILE))
|
||||
|
||||
# Update CHANGELOG.md (Gap 3 fix)
|
||||
update_changelog(changelog)
|
||||
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
|
||||
# Verify tests pass BEFORE committing or tagging.
|
||||
# This ensures we never release a version that fails tests.
|
||||
if skip_tests:
|
||||
click.echo(_("WARNING: --skip-tests passed — skipping test verification."))
|
||||
else:
|
||||
run_tests()
|
||||
|
||||
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
|
||||
committed = commit_release_changes(new_version)
|
||||
if committed:
|
||||
click.echo(_("Created release commit."))
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
# Create and push tag (Gap 4: handles existing tag)
|
||||
created = create_and_push_tag(new_version, changelog, dry_run)
|
||||
if created:
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/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 = Path(__file__).resolve().parent.parent.parent.parent / "docs"
|
||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
|
||||
def load_mapping() -> dict[str, str]:
|
||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
||||
with open(MAPPING_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
"""Read markdown content from a docs file."""
|
||||
full_path = DOCS_DIR / file_path
|
||||
with open(full_path) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def encode_content(content: str) -> str:
|
||||
"""Encode content as base64 for the Gitea wiki API.
|
||||
|
||||
The Gitea wiki API requires content_base64, not plain content.
|
||||
Sending plain content silently fails (pages are created/updated
|
||||
but with empty content).
|
||||
"""
|
||||
return base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decode_content(content_b64: str) -> str:
|
||||
"""Decode base64 content from the Gitea wiki API."""
|
||||
if not content_b64:
|
||||
return ""
|
||||
return base64.b64decode(content_b64).decode("utf-8")
|
||||
|
||||
|
||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||
"""List existing wiki pages, returning {title: sub_url}."""
|
||||
try:
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
except APIError:
|
||||
return {}
|
||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||
|
||||
|
||||
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
|
||||
"""Fetch a wiki page's content by sub_url, decoded from base64."""
|
||||
try:
|
||||
page = client._request("GET", f"/wiki/page/{sub_url}").json()
|
||||
return decode_content(page.get("content_base64", ""))
|
||||
except APIError:
|
||||
return ""
|
||||
|
||||
|
||||
def sync_page(
|
||||
client: GiteaClient,
|
||||
page_title: str,
|
||||
content: str,
|
||||
existing_pages: dict[str, str],
|
||||
dry_run: bool,
|
||||
) -> str:
|
||||
"""Create or update a single wiki page.
|
||||
|
||||
Returns "created", "updated", or "skipped" (if dry-run).
|
||||
"""
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
||||
return "skipped"
|
||||
|
||||
content_b64 = encode_content(content)
|
||||
|
||||
if page_title in existing_pages:
|
||||
# Update existing page via PATCH
|
||||
sub_url = existing_pages[page_title]
|
||||
client._request(
|
||||
"PATCH",
|
||||
f"/wiki/page/{sub_url}",
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — update {page_title}",
|
||||
},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
# Create new page via POST /wiki/new
|
||||
client._request(
|
||||
"POST",
|
||||
"/wiki/new",
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — create {page_title}",
|
||||
},
|
||||
)
|
||||
return "created"
|
||||
|
||||
|
||||
def verify_wiki_page(
|
||||
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
|
||||
) -> bool:
|
||||
"""Verify that a wiki page has non-empty content matching the docs.
|
||||
|
||||
Returns True if the page content matches, False otherwise.
|
||||
"""
|
||||
if page_title not in existing_pages:
|
||||
return False
|
||||
sub_url = existing_pages[page_title]
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
return actual.strip() == expected_content.strip()
|
||||
|
||||
|
||||
def verify_wiki_integrity(
|
||||
client: GiteaClient,
|
||||
mapping: dict[str, str],
|
||||
synced: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Comprehensive wiki verification.
|
||||
|
||||
Checks:
|
||||
1. Every mapped page exists in the wiki
|
||||
2. Every mapped page has non-empty content
|
||||
3. Every mapped page's content matches the docs
|
||||
4. No stale pages exist in the wiki (pages not in mapping)
|
||||
5. Page count matches
|
||||
|
||||
Returns a list of failure messages (empty if all checks pass).
|
||||
"""
|
||||
failures: list[str] = []
|
||||
existing_pages = list_wiki_pages(client)
|
||||
expected_titles = set(mapping.values())
|
||||
|
||||
# Check 1: Page count
|
||||
if len(existing_pages) != len(expected_titles):
|
||||
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
|
||||
|
||||
# Check 2: Missing pages (in mapping but not in wiki)
|
||||
missing = expected_titles - set(existing_pages.keys())
|
||||
for title in sorted(missing):
|
||||
failures.append(f"Missing page: {title}")
|
||||
|
||||
# Check 3: Stale pages (in wiki but not in mapping)
|
||||
stale = set(existing_pages.keys()) - expected_titles
|
||||
for title in sorted(stale):
|
||||
failures.append(f"Stale page (not in mapping): {title}")
|
||||
|
||||
# Check 4: Content verification
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if not ok:
|
||||
sub_url = existing_pages.get(page_title, "?")
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
if not actual.strip():
|
||||
failures.append(f"Empty content: {page_title}")
|
||||
else:
|
||||
failures.append(f"Content mismatch: {page_title}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
|
||||
@click.option(
|
||||
"--verify",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
if repo is None:
|
||||
owner = os.environ.get("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:
|
||||
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not content.strip():
|
||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
created += 1
|
||||
click.echo(_(" Created: {title}", title=page_title))
|
||||
elif result == "updated":
|
||||
updated += 1
|
||||
click.echo(_(" Updated: {title}", title=page_title))
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
synced[page_title] = content
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
created=created,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
)
|
||||
)
|
||||
|
||||
# --strict implies --verify
|
||||
do_verify = verify or strict
|
||||
|
||||
if do_verify and not dry_run:
|
||||
if strict:
|
||||
click.echo(_("\nRunning full wiki integrity check..."))
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
if failures:
|
||||
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
|
||||
for f in failures:
|
||||
click.echo(f" - {f}")
|
||||
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
|
||||
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
|
||||
else:
|
||||
click.echo(_("\nVerifying wiki pages have content..."))
|
||||
# Re-fetch the page list to get updated sub_urls
|
||||
existing_pages = list_wiki_pages(client)
|
||||
failures = 0
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if ok:
|
||||
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
|
||||
else:
|
||||
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
|
||||
failures += 1
|
||||
if failures > 0:
|
||||
click.echo(
|
||||
_(
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
failures=failures,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
|
||||
)
|
||||
click.echo(_("\nVerification passed — all wiki pages have correct content."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate commit messages for devx.
|
||||
|
||||
Rules:
|
||||
- On feature branches: conventional commits ONLY, must NOT include DEVX-N prefix.
|
||||
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
|
||||
e.g. 'DEVX-24: fix: resolve timeout'.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import CONVENTIONAL_RE
|
||||
from devx.i18n import _
|
||||
|
||||
MASTER_TASK_ID_RE = re.compile(r"^DEVX-\d+:")
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
return text.split("\n")[0]
|
||||
|
||||
|
||||
def get_branch() -> str:
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg_file")
|
||||
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
||||
def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
|
||||
if branch is None:
|
||||
branch = get_branch()
|
||||
subject = first_line(msg)
|
||||
|
||||
if branch == "master":
|
||||
if not MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commits must start with a task ID.\n"
|
||||
" Expected: DEVX-N: <conventional commit message>\n"
|
||||
" Got: {subject}",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
|
||||
if not CONVENTIONAL_RE.match(remainder):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n"
|
||||
" Expected: DEVX-N: <type>: <description>\n"
|
||||
" Got: {subject}",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI."
|
||||
)
|
||||
)
|
||||
|
||||
if not CONVENTIONAL_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Commit message must follow conventional commit format.\n"
|
||||
" Expected: <type>: <description>\n"
|
||||
" Got: {subject}\n"
|
||||
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
|
||||
" perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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))
|
||||
|
||||
|
||||
@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("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,36 @@
|
||||
"""Shared configuration constants for devx scripts and API clients.
|
||||
|
||||
All defaults can be overridden via environment variables with the ``DEVX_``
|
||||
prefix. Projects consuming devx can set these in their ``.env`` files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
||||
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
||||
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
||||
|
||||
# Vikunja project ID — each project uses a different Vikunja project
|
||||
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
|
||||
|
||||
# HTTP client defaults
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_PER_PAGE = 50
|
||||
|
||||
# Retry configuration for transient errors (429, 5xx, connection errors)
|
||||
MAX_RETRIES = 3
|
||||
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
|
||||
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||
|
||||
# Conventional commit regex — used by validate_commit_msg.py
|
||||
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
|
||||
@@ -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,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
|
||||
|
||||
This module provides a programmatic interface to the ``tea`` CLI tool,
|
||||
parsing JSON output for structured data. It is used by CI scripts to
|
||||
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
|
||||
for reliability.
|
||||
|
||||
The wrapper requires ``tea`` to be installed and configured (run
|
||||
``make setup`` which calls ``scripts/install_tools.py`` and
|
||||
``scripts/setup.py``).
|
||||
|
||||
Operations supported via tea:
|
||||
- Creating pull requests
|
||||
- Creating issues
|
||||
- Adding labels to issues/PRs
|
||||
- Creating labels
|
||||
- Merging pull requests
|
||||
- Creating releases
|
||||
- Posting reviews on PRs
|
||||
- Listing branches
|
||||
|
||||
Operations NOT supported via tea (still use GiteaClient):
|
||||
- Wiki page management
|
||||
- Commit status checks
|
||||
- Runner discovery
|
||||
- PR file/commit listing (tea has limited support)
|
||||
- Branch protection with detailed config (tea only has basic protect/unprotect)
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.gitea_cli import TeaCLI
|
||||
|
||||
tea = TeaCLI()
|
||||
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
|
||||
tea.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
||||
|
||||
All methods parse JSON output from tea for structured access.
|
||||
Commands are run with ``--output json`` where structured data is expected.
|
||||
"""
|
||||
|
||||
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
|
||||
"""Initialize the tea CLI wrapper.
|
||||
|
||||
Args:
|
||||
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
|
||||
repo: Default repo in ``owner/name`` format for commands that need it.
|
||||
"""
|
||||
self._tea = tea_bin or shutil.which("tea") or "tea"
|
||||
self._repo = repo
|
||||
|
||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||
"""Run a tea command and return stdout.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without the leading ``tea``).
|
||||
json_output: If True, append ``--output json`` to the command.
|
||||
|
||||
Returns:
|
||||
stdout as a string.
|
||||
|
||||
Raises:
|
||||
TeaCLIError: If the command fails.
|
||||
"""
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_raw(self, args: list[str]) -> str:
|
||||
"""Run a tea command without JSON output and return stdout."""
|
||||
return self._run(args, json_output=False)
|
||||
|
||||
def _repo_arg(self, repo: str | None = None) -> list[str]:
|
||||
"""Build the --repo argument list."""
|
||||
target = repo or self._repo
|
||||
if target:
|
||||
return ["--repo", target]
|
||||
return []
|
||||
|
||||
# -- Issues --
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
labels: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create an issue and return the issue dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: Issue title.
|
||||
body: Issue body (markdown).
|
||||
labels: List of label names to attach.
|
||||
|
||||
Returns:
|
||||
The created issue as a dict (parsed from tea JSON output).
|
||||
"""
|
||||
args = ["issues", "create", "--title", title, "--description", body, *self._repo_arg(repo)]
|
||||
output = self._run(args, json_output=False)
|
||||
# tea issues create doesn't output JSON; extract issue number from output
|
||||
# Format: "Created issue #42: <title>"
|
||||
issue_index = _extract_issue_number(output)
|
||||
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
|
||||
|
||||
# -- Labels --
|
||||
|
||||
def list_labels(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all labels for a repository."""
|
||||
output = self._run(["labels", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
def create_label(
|
||||
self,
|
||||
repo: str,
|
||||
name: str,
|
||||
color: str = "",
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a label. Returns the label dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
name: Label name.
|
||||
color: Hex color (without #), e.g. ``2ecc71``.
|
||||
description: Label description.
|
||||
"""
|
||||
args = ["labels", "create", name, *self._repo_arg(repo)]
|
||||
if color:
|
||||
args.extend(["--color", f"#{color}"])
|
||||
if description:
|
||||
args.extend(["--description", description])
|
||||
output = self._run(args, json_output=False)
|
||||
return {"name": name, "color": color, "description": description, "output": output}
|
||||
|
||||
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
|
||||
"""Add labels to an issue or PR.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
issue_index: Issue or PR number.
|
||||
labels: List of label names to add.
|
||||
"""
|
||||
for _label in labels:
|
||||
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
|
||||
return # tea edit handles all labels at once
|
||||
# No labels to add — nothing to do
|
||||
|
||||
# -- Pull Requests --
|
||||
|
||||
def create_pr(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
head: str,
|
||||
base: str,
|
||||
body: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pull request and return the PR dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: PR title.
|
||||
head: Head branch name.
|
||||
base: Base branch name.
|
||||
body: PR description (markdown).
|
||||
"""
|
||||
args = [
|
||||
"pulls",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--base",
|
||||
base,
|
||||
"--head",
|
||||
head,
|
||||
*self._repo_arg(repo),
|
||||
]
|
||||
if body:
|
||||
args.extend(["--body", body])
|
||||
output = self._run(args, json_output=False)
|
||||
pr_index = _extract_pr_number(output)
|
||||
return {"title": title, "index": pr_index, "url": output.strip()}
|
||||
|
||||
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
|
||||
"""Merge a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
|
||||
"""
|
||||
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
|
||||
|
||||
def review_pr(
|
||||
self,
|
||||
repo: str,
|
||||
pr_index: int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
) -> None:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
|
||||
body: Review body text.
|
||||
"""
|
||||
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
|
||||
if event == "APPROVE":
|
||||
args.append("--approve")
|
||||
elif event == "REQUEST_CHANGES":
|
||||
args.extend(["--reject"])
|
||||
if body:
|
||||
args.extend(["--comment", body])
|
||||
self._run_raw(args)
|
||||
|
||||
# -- Releases --
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
repo: str,
|
||||
tag: str,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
target: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release and return the release dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
tag: Tag name (e.g. ``v1.0.0``).
|
||||
title: Release title.
|
||||
body: Release notes (markdown).
|
||||
target: Target branch/commit for the tag.
|
||||
draft: If True, create as draft.
|
||||
prerelease: If True, mark as prerelease.
|
||||
"""
|
||||
args = ["releases", "create", tag, *self._repo_arg(repo)]
|
||||
if title:
|
||||
args.extend(["--title", title])
|
||||
if body:
|
||||
args.extend(["--note", body])
|
||||
if target:
|
||||
args.extend(["--target", target])
|
||||
if draft:
|
||||
args.append("--draft")
|
||||
if prerelease:
|
||||
args.append("--prerelease")
|
||||
output = self._run(args, json_output=False)
|
||||
return {"tag": tag, "title": title, "url": output.strip()}
|
||||
|
||||
def list_releases(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all releases for a repository."""
|
||||
output = self._run(["releases", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Branches --
|
||||
|
||||
def list_branches(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all branches for a repository."""
|
||||
output = self._run(["branches", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Utility --
|
||||
|
||||
def whoami(self) -> str:
|
||||
"""Return the current authenticated user."""
|
||||
return self._run_raw(["whoami"])
|
||||
|
||||
|
||||
def _extract_issue_number(output: str) -> int:
|
||||
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
|
||||
for part in output.split():
|
||||
if part.startswith("#"):
|
||||
try:
|
||||
return int(part[1:].rstrip(":"))
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_pr_number(output: str) -> int:
|
||||
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
|
||||
return _extract_issue_number(output)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Simple i18n for devx scripts and tools.
|
||||
|
||||
Set DEVX_LANG environment variable to override the default English.
|
||||
Supported: en, bg, de, ru, zh.
|
||||
|
||||
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
|
||||
JSON file with additional keys. Keys from the project's file are merged
|
||||
on top of devx's built-in translations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Load built-in translations
|
||||
_BUILTIN_TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
|
||||
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _load_project_translations() -> dict[str, dict[str, str]]:
|
||||
"""Load project-specific translations from DEVX_TRANSLATIONS_PATH if set."""
|
||||
path = os.getenv("DEVX_TRANSLATIONS_PATH")
|
||||
if not path:
|
||||
return {}
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
# Merge project translations on top of built-in
|
||||
TRANSLATIONS: dict[str, dict[str, str]] = {**_BUILTIN_TRANSLATIONS, **_load_project_translations()}
|
||||
|
||||
|
||||
def _(key: str, **kwargs: object) -> str:
|
||||
"""Return a translated string for the given key.
|
||||
|
||||
Translation is opt-in via the ``DEVX_LANG`` environment variable.
|
||||
If unset, English is always returned regardless of system locale.
|
||||
"""
|
||||
lang = os.getenv("DEVX_LANG", "en")
|
||||
if lang not in ("en", "bg", "de", "ru", "zh"):
|
||||
lang = "en"
|
||||
template = TRANSLATIONS.get(key, {}).get(lang, key)
|
||||
return template.format(**kwargs)
|
||||
@@ -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,192 @@
|
||||
#!/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
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestPair:
|
||||
"""A (scenario, platform) combination to test."""
|
||||
|
||||
scenario: str
|
||||
platform: dict[str, str]
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string for CI consumption."""
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> TestPair:
|
||||
"""Deserialize from a pipe-delimited string."""
|
||||
parts = encoded.split("|")
|
||||
return TestPair(
|
||||
scenario=parts[0],
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
|
||||
)
|
||||
|
||||
|
||||
def discover_scenarios(root: Path | None = None) -> list[str]:
|
||||
"""Return sorted list of molecule scenario directory names."""
|
||||
if root is None:
|
||||
root = MOLECULE_ROOT
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
|
||||
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
|
||||
return sorted(scenarios)
|
||||
|
||||
|
||||
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
|
||||
"""Build the full cross-product of scenarios and platforms."""
|
||||
if platforms is None:
|
||||
platforms = PLATFORMS
|
||||
return [TestPair(s, p) for s in scenarios for p in platforms]
|
||||
|
||||
|
||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
||||
for i, pair in enumerate(pairs):
|
||||
groups[i % max_runners].append(pair)
|
||||
return groups
|
||||
|
||||
|
||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
||||
"""Return the subset of pairs assigned to *runner_index*."""
|
||||
groups = distribute(pairs, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Runner index {index} out of range (0..{max})",
|
||||
index=runner_index,
|
||||
max=max_runners - 1,
|
||||
)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
||||
import os
|
||||
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="One-based runner index (Gitea Actions renders 0 as empty). "
|
||||
"Converted to zero-based internally. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RUNNERS,
|
||||
show_default=True,
|
||||
help="Total number of parallel runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--list",
|
||||
"list_all",
|
||||
is_flag=True,
|
||||
help="List all discovered scenarios, one per line.",
|
||||
)
|
||||
@click.option(
|
||||
"--list-platforms",
|
||||
"list_platforms",
|
||||
is_flag=True,
|
||||
help="List all supported platforms, one per line.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-env",
|
||||
"github_env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def cli(
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
list_all: bool,
|
||||
list_platforms: bool,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
) -> None:
|
||||
scenarios = discover_scenarios()
|
||||
if list_all:
|
||||
for s in scenarios:
|
||||
click.echo(s)
|
||||
return
|
||||
if list_platforms:
|
||||
for p in PLATFORMS:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs = build_pairs(scenarios)
|
||||
if runner_index is None:
|
||||
groups = distribute(pairs, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
|
||||
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("TEST_PAIRS", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
# Convert 1-based CLI index to 0-based internal index
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
encoded = " ".join(p.encode() for p in assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("TEST_PAIRS", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned pairs: {encoded}")
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -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,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
||||
|
||||
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
|
||||
Pairs are executed one at a time (molecule scenarios share temp directories and
|
||||
Docker networks, so parallel execution within a single runner is unsafe).
|
||||
|
||||
A background thread polls the Gitea API. If any other molecule matrix runner
|
||||
reports failure, the current molecule subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.molecule.molecule_ci_guard <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 build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Build environment for a single molecule pair."""
|
||||
scenario, platform_name, platform_image, platform_command = pair.split("|")
|
||||
env = base_env.copy()
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
||||
if platform_command:
|
||||
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
|
||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
return env
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pairs", nargs=-1, required=True)
|
||||
def cli(pairs: tuple[str, ...]) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
owner, sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
|
||||
if gitea_url and token and run_id:
|
||||
poller = threading.Thread(
|
||||
target=poll_for_other_failures,
|
||||
args=(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
run_id,
|
||||
job_name,
|
||||
current_index,
|
||||
stop_event,
|
||||
failed_event,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
poller.start()
|
||||
|
||||
try:
|
||||
for pair in pairs:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
env = build_env_for_pair(pair, base_env)
|
||||
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if failed_event.is_set():
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
if rc != 0:
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""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
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires
|
||||
#: loginctl/systemctl --user.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run unit tests and enforce a maximum execution-time budget.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.check_test_speed [--max-seconds N]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_SECONDS = 2.0
|
||||
TEST_COMMAND = ["make", "test-unit"]
|
||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
"""Execute the unit-test suite and return (stdout, stderr)."""
|
||||
result = subprocess.run( # nosec B603
|
||||
TEST_COMMAND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
|
||||
def parse_duration(output: str) -> float:
|
||||
"""Extract elapsed seconds from pytest summary line.
|
||||
|
||||
Raises:
|
||||
click.ClickException: when the timing line cannot be found.
|
||||
"""
|
||||
for line in output.splitlines():
|
||||
match = _TIMING_RE.search(line)
|
||||
if match:
|
||||
return float(match.group(2))
|
||||
raise click.ClickException(_("Could not parse test execution time from output."))
|
||||
|
||||
|
||||
def check_speed(duration: float, max_seconds: float) -> None:
|
||||
"""Validate duration is within budget; raise on violation."""
|
||||
if duration > max_seconds:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
|
||||
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
|
||||
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main(max_seconds: float) -> None:
|
||||
"""Run tests, parse timing, and enforce the budget."""
|
||||
stdout, stderr = run_tests()
|
||||
combined = stdout + "\n" + stderr
|
||||
click.echo(combined, err=False)
|
||||
|
||||
duration = parse_duration(combined)
|
||||
check_speed(duration, max_seconds)
|
||||
click.echo(
|
||||
_(
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--max-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_MAX_SECONDS,
|
||||
show_default=True,
|
||||
help="Maximum allowed execution time in seconds.",
|
||||
)
|
||||
def cli(max_seconds: float) -> None:
|
||||
main(max_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure repository: branch protection + repo settings via Gitea REST API.
|
||||
|
||||
Uses ``GiteaClient`` for branch protection and repo settings.
|
||||
The ``tea`` CLI is used for label creation if available, with a
|
||||
fallback to ``GiteaClient`` if tea is not installed.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
|
||||
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def _default_status_checks() -> list[str]:
|
||||
"""Read status check contexts from DEVX_STATUS_CHECKS env var or use default."""
|
||||
env_checks = os.environ.get("DEVX_STATUS_CHECKS", "")
|
||||
if env_checks:
|
||||
return [c.strip() for c in env_checks.split(",") if c.strip()]
|
||||
return ["CI / quality (pull_request)"]
|
||||
|
||||
|
||||
def _default_branch_protection_config() -> dict[str, Any]:
|
||||
"""Build the default branch protection config.
|
||||
|
||||
The ``status_check_contexts`` are read from the ``DEVX_STATUS_CHECKS``
|
||||
environment variable (comma-separated) or default to just the quality
|
||||
check context.
|
||||
|
||||
Push whitelist is disabled — the release script pushes directly to
|
||||
master (release commits). Since there are no manual reviews yet,
|
||||
requiring PRs for every push adds complexity without benefit.
|
||||
"""
|
||||
return {
|
||||
"branch_name": "master",
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": False,
|
||||
"push_whitelist_usernames": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": _default_status_checks(),
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_on_outdated_branch": True,
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": True,
|
||||
}
|
||||
|
||||
|
||||
def _default_repo_settings_config() -> dict[str, Any]:
|
||||
"""Build the default repository settings config."""
|
||||
return {
|
||||
"default_delete_branch_after_merge": True,
|
||||
}
|
||||
|
||||
|
||||
def _handle_http_error(e: APIError) -> None:
|
||||
"""Raise a user-friendly Click exception for HTTP errors."""
|
||||
if e.status == http.HTTPStatus.FORBIDDEN:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"HTTP {status} Forbidden — your token lacks admin rights.\n"
|
||||
"Make sure the token belongs to a repo owner or organisation admin.\n"
|
||||
"Alternatively, configure branch protection manually in Settings → Branches.",
|
||||
status=e.status,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(_("HTTP error: {status} — {message}", status=e.status, message=e.message))
|
||||
|
||||
|
||||
def configure_repo(
|
||||
token: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str = "master",
|
||||
branch_protection_config: dict[str, Any] | None = None,
|
||||
repo_settings_config: dict[str, Any] | None = None,
|
||||
api_url: str | None = None,
|
||||
) -> None:
|
||||
"""Configure branch protection and repository settings via the Gitea API.
|
||||
|
||||
Args:
|
||||
token: Gitea API token with admin rights.
|
||||
owner: Repository owner (user or organisation).
|
||||
repo: Repository name.
|
||||
branch: Branch to protect (default: ``master``).
|
||||
branch_protection_config: Branch protection settings dict.
|
||||
If None, uses defaults from :func:`_default_branch_protection_config`.
|
||||
repo_settings_config: Repository settings dict.
|
||||
If None, uses defaults from :func:`_default_repo_settings_config`.
|
||||
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
|
||||
"""
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
url = api_url or GITEA_API_URL
|
||||
client = GiteaClient(url, token, owner, repo)
|
||||
|
||||
bp_config = branch_protection_config or _default_branch_protection_config()
|
||||
rs_config = repo_settings_config or _default_repo_settings_config()
|
||||
|
||||
try:
|
||||
click.echo(_("Configuring branch protection for {branch}...", branch=branch))
|
||||
client.ensure_branch_protection(branch, bp_config)
|
||||
click.echo(_(" - Direct pushes: BLOCKED (require PR, whitelisted users can push)"))
|
||||
click.echo(
|
||||
_(
|
||||
" - Required approvals: {count}",
|
||||
count=bp_config["required_approvals"],
|
||||
)
|
||||
)
|
||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
||||
click.echo(_(" - Block outdated branches: yes"))
|
||||
click.echo(_(" - Block rejected reviews: yes"))
|
||||
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
|
||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Configuring repository settings..."))
|
||||
client.update_repo_settings(cast(dict[str, object], rs_config))
|
||||
click.echo(_(" - Auto-delete branch after merge: yes"))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Repository configuration complete."))
|
||||
except APIError as e:
|
||||
_handle_http_error(e)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", default=None, help="Repository name (default: from DEVX_REPO_NAME env var).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: from DEVX_REPO_OWNER env var).")
|
||||
@click.option("--branch", default="master", help="Branch to protect (default: master).")
|
||||
@click.option(
|
||||
"--api-url",
|
||||
default=None,
|
||||
help="Gitea API base URL (default: from DEVX_GITEA_API_URL env var).",
|
||||
)
|
||||
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
|
||||
"""Configure branch protection and repository settings via the Gitea API."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
if owner is None:
|
||||
owner = REPO_OWNER
|
||||
|
||||
configure_repo(
|
||||
token=token,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
api_url=api_url,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate self-contained SVG badge files from project metrics.
|
||||
|
||||
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
||||
then writes SVG badge files that can be served as static files from
|
||||
the Gitea raw file API.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.generate_badges --output-dir .badges/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
||||
|
||||
# shields.io color names to hex values
|
||||
COLOR_HEX: dict[str, str] = {
|
||||
"brightgreen": "#4c1",
|
||||
"green": "#97ca00",
|
||||
"yellowgreen": "#a4a61d",
|
||||
"yellow": "#dfb317",
|
||||
"orange": "#fe7d37",
|
||||
"red": "#e05d44",
|
||||
"blue": "#007ec6",
|
||||
"lightgrey": "#9f9f9f",
|
||||
}
|
||||
|
||||
|
||||
def _find_package_init() -> Path | None:
|
||||
"""Find the first package __init__.py under src/ that defines __version__."""
|
||||
src_dir = REPO_ROOT / "src"
|
||||
if not src_dir.exists():
|
||||
return None
|
||||
for init_file in src_dir.rglob("__init__.py"):
|
||||
try:
|
||||
content = init_file.read_text()
|
||||
except OSError:
|
||||
continue
|
||||
if "__version__" in content:
|
||||
return init_file
|
||||
return None
|
||||
|
||||
|
||||
def _xml_escape(text: str) -> str:
|
||||
"""Escape XML special characters."""
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def run_command(cmd: list[str]) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
|
||||
"""Build a badge data dict."""
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
|
||||
def render_svg(label: str, message: str, color: str) -> str:
|
||||
"""Render a shields.io-style SVG badge."""
|
||||
color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f")
|
||||
|
||||
# Approximate text width: 7px per character + 10px padding
|
||||
label_text = _xml_escape(label)
|
||||
message_text = _xml_escape(message)
|
||||
label_w = max(len(label) * 7 + 10, 30)
|
||||
message_w = max(len(message) * 7 + 10, 30)
|
||||
total_w = label_w + message_w
|
||||
|
||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img"
|
||||
aria-label="{label_text}: {message_text}">
|
||||
<title>{label_text}: {message_text}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="{label_w}" height="20" fill="#555"/>
|
||||
<rect x="{label_w}" width="{message_w}" height="20" fill="{color_hex}"/>
|
||||
<rect width="{total_w}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="{label_w // 2}" y="14">{label_text}</text>
|
||||
<text x="{label_w + message_w // 2}" y="14">{message_text}</text>
|
||||
</g>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
|
||||
def extract_coverage(output: str) -> float | None:
|
||||
"""Extract total coverage percentage from pytest-cov output."""
|
||||
for line in output.splitlines():
|
||||
match = _COVERAGE_RE.search(line)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_test_count(output: str) -> int | None:
|
||||
"""Extract number of passed tests from pytest output."""
|
||||
for line in output.splitlines():
|
||||
match = _PASSED_RE.search(line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_doc_coverage(output: str) -> int | None:
|
||||
"""Extract doc coverage percentage from doc_coverage.py output."""
|
||||
match = _DOC_COVERAGE_RE.search(output)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read __version__ from the package __init__.py."""
|
||||
init_file = _find_package_init()
|
||||
if init_file is None:
|
||||
return "unknown"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def coverage_color(pct: float) -> str:
|
||||
"""Map coverage percentage to a color."""
|
||||
if pct >= 100:
|
||||
return "brightgreen"
|
||||
if pct >= 90:
|
||||
return "green"
|
||||
if pct >= 80:
|
||||
return "yellowgreen"
|
||||
if pct >= 70:
|
||||
return "yellow"
|
||||
if pct >= 60:
|
||||
return "orange"
|
||||
return "red"
|
||||
|
||||
|
||||
def doc_coverage_color(pct: int) -> str:
|
||||
"""Map doc coverage percentage to a color."""
|
||||
if pct >= 100:
|
||||
return "brightgreen"
|
||||
if pct >= 90:
|
||||
return "green"
|
||||
if pct >= 80:
|
||||
return "yellowgreen"
|
||||
if pct >= 70:
|
||||
return "yellow"
|
||||
return "orange"
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict."""
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
rc, stdout, stderr = run_command(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--cov=src/devx",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
]
|
||||
)
|
||||
combined = stdout + "\n" + stderr
|
||||
|
||||
coverage = extract_coverage(combined)
|
||||
if coverage is not None:
|
||||
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
badges["coverage"] = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
badges["tests"] = make_badge("tests", "unknown", "red")
|
||||
|
||||
# 2. Documentation coverage
|
||||
rc, stdout, _ = run_command(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"devx.ci.doc_coverage",
|
||||
]
|
||||
)
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
else:
|
||||
badges["docs"] = make_badge("docs", "unknown", "red")
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit all pass)
|
||||
lint_rc, _, _ = run_command([sys.executable, "-m", "ruff", "check", "src/", "tests/"])
|
||||
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"])
|
||||
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
|
||||
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
|
||||
|
||||
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
|
||||
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
# 4. Version
|
||||
version = read_version()
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
# 5. Python version (static but nice)
|
||||
badges["python"] = make_badge("python", "3.12", "blue")
|
||||
|
||||
# Write SVG files
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, badge in badges.items():
|
||||
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
|
||||
path = output_dir / f"{name}.svg"
|
||||
path.write_text(svg)
|
||||
click.echo(f" Generated: {path}")
|
||||
|
||||
return badges
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=str(REPO_ROOT / ".badges"),
|
||||
help="Directory to write badge SVG files.",
|
||||
)
|
||||
def cli(output_dir: str) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
badges = generate_badges(out)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
for name, badge in badges.items():
|
||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -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,177 @@
|
||||
#!/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."""
|
||||
pip = str(Path(bin_dir) / "pip")
|
||||
_run([pip, "install", "-e", f".[{extras}]"])
|
||||
|
||||
|
||||
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 = str(Path(bin_dir) / "ansible-galaxy")
|
||||
requirements = Path("ansible/requirements.yml")
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
return
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
"""Configure tea CLI login from .env if REPO_TOKEN is set.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips if tea is not installed or REPO_TOKEN is not set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("tea: not installed — run 'make install-tools' to install it.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
gitea_url = api_url.replace("/api/v1", "")
|
||||
login_name = "devx"
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "list", "--output", "simple"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and login_name in result.stdout:
|
||||
click.echo(f"tea: login '{login_name}' already configured.")
|
||||
return
|
||||
|
||||
click.echo(f"tea: configuring login '{login_name}' for {gitea_url}...")
|
||||
add_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
click.echo(f"tea: login configuration failed: {add_result.stderr.strip()}", err=True)
|
||||
else:
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
click.echo(f"tea: login '{login_name}' configured and set as default.")
|
||||
|
||||
|
||||
def _verify(bin_dir: str) -> None:
|
||||
"""Print versions of installed tools for verification."""
|
||||
devx = str(Path(bin_dir) / "devx")
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for tool in [devx, pre_commit]:
|
||||
try:
|
||||
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
|
||||
if result.returncode == 0:
|
||||
click.echo(f" {result.stdout.strip()}")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
@click.option(
|
||||
"--extras",
|
||||
default="dev",
|
||||
help="Dependency group to install: ci, lint, or dev (default: dev).",
|
||||
)
|
||||
@click.option(
|
||||
"--no-pre-commit",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip pre-commit hook installation.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-tea-login",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip tea CLI login configuration.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-ansible-collections",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip Ansible Galaxy collection installation.",
|
||||
)
|
||||
def main(
|
||||
bin_dir: str,
|
||||
extras: str,
|
||||
no_pre_commit: bool,
|
||||
no_tea_login: bool,
|
||||
no_ansible_collections: bool,
|
||||
) -> None:
|
||||
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
|
||||
if not Path(bin_dir).exists():
|
||||
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
||||
|
||||
click.echo(f"Installing Python dependencies (extras: {extras})...")
|
||||
_install_python_deps(bin_dir, extras)
|
||||
|
||||
if not no_ansible_collections:
|
||||
click.echo("Installing Ansible Galaxy collections...")
|
||||
_install_ansible_collections(bin_dir)
|
||||
|
||||
if not no_pre_commit:
|
||||
click.echo("Installing pre-commit hooks...")
|
||||
_install_pre_commit_hooks(bin_dir)
|
||||
|
||||
if not no_tea_login:
|
||||
click.echo("Configuring tea CLI login...")
|
||||
_configure_tea_login()
|
||||
|
||||
click.echo("")
|
||||
click.echo("Setup complete.")
|
||||
click.echo("Activate the virtual environment with one of:")
|
||||
click.echo(" source .venv/bin/activate (generic)")
|
||||
click.echo(" source activate.sh (bash)")
|
||||
click.echo(" source activate.fish (fish)")
|
||||
click.echo(" source activate.zsh (zsh)")
|
||||
click.echo("")
|
||||
|
||||
_verify(bin_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,661 @@
|
||||
{
|
||||
"\nAll documentation coverage checks passed!": {
|
||||
"en": "\nAll documentation coverage checks passed!"
|
||||
},
|
||||
"\nAnsible files changed ({count}):": {
|
||||
"en": "\nAnsible files changed ({count}):"
|
||||
},
|
||||
"\nChecking CI script documentation in ci-cd-workflow.md...": {
|
||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
||||
},
|
||||
"\nChecking module documentation in architecture.md...": {
|
||||
"en": "\nChecking module documentation in architecture.md..."
|
||||
},
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)": {
|
||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
},
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
},
|
||||
"\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."
|
||||
},
|
||||
"\nIntegrity check FAILED ({count} issues):": {
|
||||
"en": "\nIntegrity check FAILED ({count} issues):"
|
||||
},
|
||||
"\nIntegrity check passed — all {count} pages verified.": {
|
||||
"en": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nMissing documentation:": {
|
||||
"en": "\nMissing documentation:"
|
||||
},
|
||||
"\nResult: {status}": {
|
||||
"en": "\nResult: {status}"
|
||||
},
|
||||
"\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)."
|
||||
},
|
||||
"\nRunning full wiki integrity check...": {
|
||||
"en": "\nRunning full wiki integrity check..."
|
||||
},
|
||||
"\nUser-facing changes ({count}):": {
|
||||
"en": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nUser-facing files changed ({count}):": {
|
||||
"en": "\nUser-facing files changed ({count}):"
|
||||
},
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
},
|
||||
"\nVerification passed — all wiki pages have correct content.": {
|
||||
"en": "\nVerification passed — all wiki pages have correct content."
|
||||
},
|
||||
"\nVerifying wiki pages have content...": {
|
||||
"en": "\nVerifying wiki pages have content..."
|
||||
},
|
||||
"\nWorkflow-only changes ({count}):": {
|
||||
"en": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[dry-run] Changelog:\n{changelog}": {
|
||||
"en": "\n[dry-run] Changelog:\n{changelog}"
|
||||
},
|
||||
" - Auto-delete branch after merge: yes": {
|
||||
"en": " - Auto-delete branch after merge: yes",
|
||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"zh": " - 合并后自动删除分支: 是"
|
||||
},
|
||||
" - Block outdated branches: yes": {
|
||||
"en": " - Block outdated branches: yes",
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
"ru": " - Блокировать устаревшие ветки: да",
|
||||
"zh": " - 阻止过时分支: 是"
|
||||
},
|
||||
" - Block rejected reviews: yes": {
|
||||
"en": " - Block rejected reviews: yes",
|
||||
"bg": " - Блокиране на отхвърлени рецензии: да",
|
||||
"de": " - Abgelehnte Reviews blockieren: ja",
|
||||
"ru": " - Блокировать отклонённые ревью: да",
|
||||
"zh": " - 阻止被拒绝的审查: 是"
|
||||
},
|
||||
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
|
||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
||||
},
|
||||
" - Dismiss stale approvals: yes": {
|
||||
"en": " - Dismiss stale approvals: yes",
|
||||
"bg": " - Анулиране на остарели одобрения: да",
|
||||
"de": " - Veraltete Genehmigungen ablehnen: ja",
|
||||
"ru": " - Отклонять устаревшие одобрения: да",
|
||||
"zh": " - 忽略过时审批: 是"
|
||||
},
|
||||
" - Required approvals: {count}": {
|
||||
"en": " - Required approvals: {count}",
|
||||
"bg": " - Необходими одобрения: {count}",
|
||||
"de": " - Erforderliche Genehmigungen: {count}",
|
||||
"ru": " - Требуемые одобрения: {count}",
|
||||
"zh": " - 必需审批数: {count}"
|
||||
},
|
||||
" - Required status checks: {checks}": {
|
||||
"en": " - Required status checks: {checks}",
|
||||
"bg": " - Необходими проверки на състоянието: {checks}",
|
||||
"de": " - Erforderliche Status-Checks: {checks}",
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Created: {title}": {
|
||||
"en": " Created: {title}"
|
||||
},
|
||||
" FAIL: {title} — content mismatch or empty!": {
|
||||
"en": " FAIL: {title} — content mismatch or empty!"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"bg": " ЛИПСВА: devx {cmd}",
|
||||
"de": " FEHLT: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
},
|
||||
" MISSING: grm {cmd}": {
|
||||
"en": " MISSING: grm {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"en": " MISSING: {module}"
|
||||
},
|
||||
" MISSING: {script}": {
|
||||
"en": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"en": " OK: devx {cmd}",
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
},
|
||||
" OK: grm {cmd}": {
|
||||
"en": " OK: grm {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"en": " OK: {module}"
|
||||
},
|
||||
" OK: {script}": {
|
||||
"en": " OK: {script}"
|
||||
},
|
||||
" OK: {title} ({chars} chars)": {
|
||||
"en": " OK: {title} ({chars} chars)"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"en": " Updated: {title}"
|
||||
},
|
||||
"API poll warning: {exc}": {
|
||||
"en": "API poll warning: {exc}"
|
||||
},
|
||||
"All molecule tests passed.": {
|
||||
"en": "All molecule tests passed."
|
||||
},
|
||||
"Another molecule runner failed. Stopping this runner early.": {
|
||||
"en": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"Bumping version: {current} -> v{new_version}": {
|
||||
"en": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"Checking CLI command documentation...": {
|
||||
"en": "Checking CLI command documentation..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"en": "Command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"Comparing {base}..{head} ({count} files changed)": {
|
||||
"en": "Comparing {base}..{head} ({count} files changed)"
|
||||
},
|
||||
"Configuring branch protection for {branch}...": {
|
||||
"en": "Configuring branch protection for {branch}...",
|
||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||
"ru": "Настройка защиты ветки {branch}...",
|
||||
"zh": "正在配置 {branch} 的分支保护..."
|
||||
},
|
||||
"Configuring repository settings...": {
|
||||
"en": "Configuring repository settings...",
|
||||
"bg": "Конфигуриране на настройките на хранилището...",
|
||||
"de": "Repository-Einstellungen konfigurieren...",
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"en": "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.": {
|
||||
"en": "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}": {
|
||||
"en": "Could not find __version__ in {file}"
|
||||
},
|
||||
"Could not parse test execution time from output.": {
|
||||
"en": "Could not parse test execution time from output."
|
||||
},
|
||||
"Created issue #{issue_id}: {title}": {
|
||||
"en": "Created issue #{issue_id}: {title}"
|
||||
},
|
||||
"Created release commit.": {
|
||||
"en": "Created release commit."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"ERROR: REPO_TOKEN is not set.": {
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
|
||||
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
||||
"zh": "错误:未设置 REPO_TOKEN。"
|
||||
},
|
||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
||||
},
|
||||
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
|
||||
"en": "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.",
|
||||
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
|
||||
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
|
||||
},
|
||||
"ERROR: mapping.json not found at {path}": {
|
||||
"en": "ERROR: mapping.json not found at {path}"
|
||||
},
|
||||
"FAILED: {pair} exited with code {code}": {
|
||||
"en": "FAILED: {pair} exited with code {code}"
|
||||
},
|
||||
"Failed to create issue via tea: {error}": {
|
||||
"en": "Failed to create issue via tea: {error}"
|
||||
},
|
||||
"Found {count} existing wiki pages.": {
|
||||
"en": "Found {count} existing wiki pages."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
|
||||
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
|
||||
},
|
||||
"HTTP error: {status} — {message}": {
|
||||
"en": "HTTP error: {status} — {message}",
|
||||
"bg": "HTTP грешка: {status} — {message}",
|
||||
"de": "HTTP-Fehler: {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.": {
|
||||
"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.",
|
||||
"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.",
|
||||
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
|
||||
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
||||
},
|
||||
"Head branch is behind master. Pulling and rebasing...": {
|
||||
"en": "Head branch is behind master. Pulling and rebasing..."
|
||||
},
|
||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
||||
"en": "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}",
|
||||
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
|
||||
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
|
||||
},
|
||||
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
|
||||
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
|
||||
},
|
||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
||||
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
|
||||
},
|
||||
"Lint passed.": {
|
||||
"en": "Lint passed."
|
||||
},
|
||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
|
||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
||||
},
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
|
||||
"en": "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.",
|
||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Module {mod} has no main() function": {
|
||||
"en": "Module {mod} has no main() function",
|
||||
"bg": "Модул {mod} няма функция main()",
|
||||
"de": "Modul {mod} hat keine main()-Funktion",
|
||||
"ru": "Модуль {mod} не имеет функции main()",
|
||||
"zh": "模块 {mod} 没有 main() 函数"
|
||||
},
|
||||
"Molecule directory not found: {path}": {
|
||||
"en": "Molecule directory not found: {path}",
|
||||
"bg": "Директорията на molecule не е намерена: {path}",
|
||||
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
|
||||
"ru": "Директория molecule не найдена: {path}",
|
||||
"zh": "未找到 molecule 目录: {path}"
|
||||
},
|
||||
"Nice! Gitea release {tag} created.": {
|
||||
"en": "Nice! Gitea release {tag} created.",
|
||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
||||
"ru": "Отлично! Gitea release {tag} создан.",
|
||||
"zh": "不错!Gitea release {tag} 已创建。"
|
||||
},
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
|
||||
"en": "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.",
|
||||
"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.": {
|
||||
"en": "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.": {
|
||||
"en": "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.",
|
||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No changes between {base} and {head}.": {
|
||||
"en": "No changes between {base} and {head}."
|
||||
},
|
||||
"No staged changes — version and changelog already up to date.": {
|
||||
"en": "No staged changes — version and changelog already up to date."
|
||||
},
|
||||
"No tags found — treating all changes as user-facing.": {
|
||||
"en": "No tags found — treating all changes as user-facing."
|
||||
},
|
||||
"No unreleased changes found. Nothing to release.": {
|
||||
"en": "No unreleased changes found. Nothing to release."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||
"en": "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": {
|
||||
"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",
|
||||
"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",
|
||||
"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 (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Опа! Не включвайте идентификатор на задача (DEVX-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
||||
"de": "Ups! Keine Task-ID (DEVX-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
||||
"ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
|
||||
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (DEVX-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
|
||||
},
|
||||
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
||||
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
||||
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
|
||||
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
|
||||
},
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}": {
|
||||
"en": "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}",
|
||||
"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: DEVX-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: DEVX-N: <type>: <description>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: DEVX-N: <type>: <description>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: DEVX-N: <type>: <description>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: DEVX-N: <type>: <description>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: DEVX-N: <conventional commit message>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: DEVX-N: <conventional commit message>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: DEVX-N: <conventional commit message>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: DEVX-N: <conventional commit message>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
},
|
||||
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"bg": "Опа! Заглавието на PR трябва да следва формата 'DEVX-N: <заглавие на задача>'.\n Очаква се: {task_id}: <заглавие на задача>\n Получено: {pr_title}",
|
||||
"de": "Ups! PR-Titel muss dem Format 'DEVX-N: <Task-Titel>' folgen.\n Erwartet: {task_id}: <Task-Titel>\n Erhalten: {pr_title}",
|
||||
"ru": "Ой! Заголовок PR должен соответствовать формату 'DEVX-N: <название задачи>'.\n Ожидается: {task_id}: <название задачи>\n Получено: {pr_title}",
|
||||
"zh": "哎呀!PR 标题必须遵循格式 'DEVX-N: <任务标题>'。\n 预期格式: {task_id}: <任务标题>\n 实际: {pr_title}"
|
||||
},
|
||||
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format 'GRM-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}": {
|
||||
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
|
||||
},
|
||||
"Oops! Package build failed:\n{stderr}": {
|
||||
"en": "Oops! Package build failed:\n{stderr}",
|
||||
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
|
||||
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
|
||||
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
|
||||
"zh": "哎呀!包构建失败:\n{stderr}"
|
||||
},
|
||||
"Oops! PyPI publish failed:\n{stderr}": {
|
||||
"en": "Oops! PyPI publish failed:\n{stderr}",
|
||||
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
|
||||
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"en": "PASSED: {pair}"
|
||||
},
|
||||
"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}"
|
||||
},
|
||||
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"en": "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.",
|
||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
||||
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
|
||||
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
||||
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Published to Gitea PyPI registry.": {
|
||||
"en": "Published to Gitea PyPI registry.",
|
||||
"bg": "Публикувано в Gitea PyPI registry.",
|
||||
"de": "In der Gitea PyPI-Registry veröffentlicht.",
|
||||
"ru": "Опубликовано в Gitea PyPI registry.",
|
||||
"zh": "已发布到 Gitea PyPI registry。"
|
||||
},
|
||||
"Published to PyPI.": {
|
||||
"en": "Published to PyPI.",
|
||||
"bg": "Публикувано в PyPI.",
|
||||
"de": "In PyPI veröffentlicht.",
|
||||
"ru": "Опубликовано в PyPI.",
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"en": "Pushed release commit to master."
|
||||
},
|
||||
"Rebased and pushed. Retrying merge...": {
|
||||
"en": "Rebased and pushed. Retrying merge..."
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"en": "Release creation failed: {error}"
|
||||
},
|
||||
"Release must be run on master, currently on '{branch}'.": {
|
||||
"en": "Release must be run on master, currently on '{branch}'."
|
||||
},
|
||||
"Repository configuration complete.": {
|
||||
"en": "Repository configuration complete.",
|
||||
"bg": "Конфигурирането на хранилището е завършено.",
|
||||
"de": "Repository-Konfiguration abgeschlossen.",
|
||||
"ru": "Конфигурация репозитория завершена.",
|
||||
"zh": "仓库配置完成。"
|
||||
},
|
||||
"Runner index {index} out of range (0..{max})": {
|
||||
"en": "Runner index {index} out of range (0..{max})",
|
||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
||||
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
||||
},
|
||||
"Running lint checks...": {
|
||||
"en": "Running lint checks..."
|
||||
},
|
||||
"Running tests...": {
|
||||
"en": "Running tests..."
|
||||
},
|
||||
"Running: {scenario} on {platform}": {
|
||||
"en": "Running: {scenario} on {platform}"
|
||||
},
|
||||
"Skipping commit push — no staged changes.": {
|
||||
"en": "Skipping commit push — no staged changes."
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki...": {
|
||||
"en": "Syncing {count} documentation pages to wiki..."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"Tag {tag} already exists, skipping creation.": {
|
||||
"en": "Tag {tag} already exists, skipping creation."
|
||||
},
|
||||
"Task ID: {task_id}": {
|
||||
"en": "Task ID: {task_id}"
|
||||
},
|
||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
|
||||
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
|
||||
},
|
||||
"Tests passed.": {
|
||||
"en": "Tests passed."
|
||||
},
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
|
||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s 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.": {
|
||||
"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."
|
||||
},
|
||||
"Updated version in {init}": {
|
||||
"en": "Updated version in {init}"
|
||||
},
|
||||
"Updated {changelog_file}": {
|
||||
"en": "Updated {changelog_file}"
|
||||
},
|
||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||
"en": "WARNING: --skip-tests passed — skipping test verification."
|
||||
},
|
||||
"WARNING: File {file} is empty — skipping.": {
|
||||
"en": "WARNING: File {file} is empty — skipping."
|
||||
},
|
||||
"WARNING: File {file} not found — skipping.": {
|
||||
"en": "WARNING: File {file} not found — skipping."
|
||||
},
|
||||
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.": {
|
||||
"en": "Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
"bg": "Предупреждение: Не е намерен идентификатор на задача (DEVX-N) в съобщението за commit: {msg}. Пропускаме обновяването на Vikunja.",
|
||||
"de": "Warnung: Keine Task-ID (DEVX-N) in Commit-Nachricht gefunden: {msg}. Vikunja-Update wird übersprungen.",
|
||||
"ru": "Предупреждение: ID задачи (DEVX-N) не найден в сообщении коммита: {msg}. Пропуск обновления Vikunja.",
|
||||
"zh": "警告:提交消息中未找到任务 ID (DEVX-N): {msg}。跳过 Vikunja 更新。"
|
||||
},
|
||||
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
|
||||
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
|
||||
},
|
||||
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
|
||||
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
|
||||
},
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
|
||||
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
|
||||
},
|
||||
"Warning: git-cliff generated empty changelog.": {
|
||||
"en": "Warning: git-cliff generated empty changelog."
|
||||
},
|
||||
"Wiki integrity check failed — {count} issue(s)": {
|
||||
"en": "Wiki integrity check failed — {count} issue(s)"
|
||||
},
|
||||
"Wiki verification failed — {failures} page(s) empty or mismatched": {
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"en": "[dry-run] Would commit: release: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"en": "[dry-run] Would create tag: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: {tag}": {
|
||||
"en": "[dry-run] Would create tag: {tag}"
|
||||
},
|
||||
"[dry-run] Would push commit to master": {
|
||||
"en": "[dry-run] Would push commit to master"
|
||||
},
|
||||
"[dry-run] Would sync page: {title} ({chars} chars)": {
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
},
|
||||
"[dry-run] Would update {changelog_file}": {
|
||||
"en": "[dry-run] Would update {changelog_file}"
|
||||
},
|
||||
"[dry-run] Would update {init}": {
|
||||
"en": "[dry-run] Would update {init}"
|
||||
},
|
||||
"active": {
|
||||
"en": "active",
|
||||
"bg": "активен",
|
||||
"de": "aktiv",
|
||||
"ru": "активен",
|
||||
"zh": "活跃"
|
||||
},
|
||||
"completed": {
|
||||
"en": "completed",
|
||||
"bg": "завършен",
|
||||
"de": "abgeschlossen",
|
||||
"ru": "завершён",
|
||||
"zh": "已完成"
|
||||
},
|
||||
"failed": {
|
||||
"en": "failed",
|
||||
"bg": "неуспешен",
|
||||
"de": "fehlgeschlagen",
|
||||
"ru": "неудачный",
|
||||
"zh": "失败"
|
||||
},
|
||||
"git command failed ({cmd}): {stderr}": {
|
||||
"en": "git command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"git-cliff returned empty version.": {
|
||||
"en": "git-cliff returned empty version."
|
||||
},
|
||||
"inactive": {
|
||||
"en": "inactive",
|
||||
"bg": "неактивен",
|
||||
"de": "inaktiv",
|
||||
"ru": "неактивен",
|
||||
"zh": "未激活"
|
||||
},
|
||||
"in_progress": {
|
||||
"en": "in progress",
|
||||
"bg": "в процес",
|
||||
"de": "in Bearbeitung",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
},
|
||||
"pending": {
|
||||
"en": "pending",
|
||||
"bg": "в очакване",
|
||||
"de": "ausstehend",
|
||||
"ru": "ожидает",
|
||||
"zh": "待处理"
|
||||
},
|
||||
"unknown": {
|
||||
"en": "unknown",
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
},
|
||||
"\n{label} files changed ({count}):": {
|
||||
"en": "\n{label} files changed ({count}):"
|
||||
},
|
||||
"\n{tag} files ({count}):": {
|
||||
"en": "\n{tag} files ({count}):"
|
||||
},
|
||||
"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}"
|
||||
},
|
||||
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
|
||||
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.": {
|
||||
"en": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping."
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="20" role="img"
|
||||
aria-label="tests: 2310 passing">
|
||||
<title>tests: 2310 passing</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="139" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="20" fill="#555"/>
|
||||
<rect x="45" width="94" height="20" fill="#4c1"/>
|
||||
<rect width="139" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="22" y="14">tests</text>
|
||||
<text x="92" y="14">2310 passing</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 909 B |
@@ -0,0 +1,651 @@
|
||||
"""Unit tests for api_clients module."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
|
||||
# Local test config — TEST_BP_CONFIG moved to configure_repo.py defaults in devx
|
||||
TEST_BP_CONFIG: dict[str, object] = {
|
||||
"branch_name": "master",
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": True,
|
||||
"push_whitelist_usernames": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": ["CI / quality (pull_request)"],
|
||||
"required_approvals": 0,
|
||||
}
|
||||
|
||||
|
||||
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
|
||||
mock = MagicMock()
|
||||
if json_data is not None:
|
||||
mock.json.return_value = json_data
|
||||
if raise_on_status:
|
||||
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
|
||||
"""Create an HTTPError with a proper response attached (for _parse_error)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"message": message or str(status_code)}
|
||||
err = requests.HTTPError(f"{status_code} {message}", response=resp)
|
||||
return err
|
||||
|
||||
|
||||
class TestParseError:
|
||||
def test_json_parse_fallback(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
|
||||
mock_response.json = MagicMock(side_effect=ValueError("not json"))
|
||||
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
|
||||
status, message = _parse_error(err)
|
||||
assert status == http.HTTPStatus.BAD_GATEWAY
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in message
|
||||
|
||||
def test_no_response(self) -> None:
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
status, message = _parse_error(err)
|
||||
assert status == 0
|
||||
assert "connection failed" in message
|
||||
|
||||
|
||||
class TestGiteaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
assert client._base_url == "https://git.example.com"
|
||||
assert client._session.headers["Authorization"] == "token tok"
|
||||
assert client._session.headers["Content-Type"] == "application/json"
|
||||
|
||||
def test_url_constructs_path(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
|
||||
|
||||
def test_url_strips_trailing_slash(self) -> None:
|
||||
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
|
||||
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
|
||||
|
||||
def test_list_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
|
||||
result = client.list_labels()
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_list_labels_raises_api_error(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
|
||||
with pytest.raises(APIError):
|
||||
client.list_labels()
|
||||
|
||||
def test_http_error_json_parse_fallback(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
|
||||
# Make json() itself raise so the except block in _parse_error is hit
|
||||
mock_response.json = MagicMock(side_effect=ValueError("not json"))
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
|
||||
client._session.request = MagicMock(return_value=mock_response)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_labels()
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
|
||||
|
||||
def test_create_label(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
|
||||
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
|
||||
assert result["name"] == "ready-to-merge"
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
|
||||
)
|
||||
|
||||
def test_ensure_label_creates_when_not_exists(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_labels = MagicMock(return_value=[])
|
||||
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
|
||||
|
||||
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is not None
|
||||
assert result["name"] == "ready-to-merge"
|
||||
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
|
||||
|
||||
def test_ensure_label_returns_none_when_exists(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
|
||||
client.create_label = MagicMock()
|
||||
|
||||
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is None
|
||||
client.create_label.assert_not_called()
|
||||
|
||||
def test_list_branch_protections(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response(
|
||||
[
|
||||
{"id": 1, "branch_name": "master"},
|
||||
{"id": 2, "branch_name": "develop"},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = client.list_branch_protections()
|
||||
assert len(result) == 2
|
||||
assert result[0]["branch_name"] == "master"
|
||||
|
||||
def test_create_branch_protection(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
|
||||
result = client.create_branch_protection(TEST_BP_CONFIG)
|
||||
assert result["id"] == 3
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/branch_protections",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json=TEST_BP_CONFIG,
|
||||
)
|
||||
|
||||
def test_update_branch_protection(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
resp = {"branch_name": "master", "required_approvals": 2}
|
||||
client._session.request = MagicMock(return_value=_mock_response(resp))
|
||||
update = {"required_approvals": 2}
|
||||
result = client.update_branch_protection("master", update)
|
||||
assert result["required_approvals"] == 2
|
||||
client._session.request.assert_called_once_with(
|
||||
"PATCH",
|
||||
"https://git.example.com/repos/owner/repo/branch_protections/master",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json=update,
|
||||
)
|
||||
|
||||
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_branch_protections = MagicMock(return_value=[])
|
||||
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
|
||||
|
||||
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
|
||||
assert result["id"] == 1
|
||||
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
|
||||
|
||||
def test_ensure_branch_protection_updates_when_exists(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}])
|
||||
client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1})
|
||||
|
||||
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
|
||||
assert result["required_approvals"] == 1
|
||||
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
|
||||
client.update_branch_protection.assert_called_once_with("master", expected_update)
|
||||
|
||||
def test_merge_pr(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.merge_pr(1, "fix: bug")
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/1/merge",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
||||
)
|
||||
|
||||
def test_get_pr_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
||||
|
||||
result = client.get_pr_labels(5)
|
||||
assert result == [{"name": "ready-to-merge"}]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/issues/5/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_commit_status(self) -> None:
|
||||
"""Uses combined status endpoint (/status, not /statuses)."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
|
||||
)
|
||||
|
||||
result = client.get_commit_status("abc123")
|
||||
assert result == [{"context": "CI / quality", "status": "success"}]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/commits/abc123/status",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
|
||||
|
||||
result = client.get_pr(7)
|
||||
assert result["number"] == 7
|
||||
assert result["head"]["sha"] == "abc123"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_files(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
|
||||
)
|
||||
|
||||
result = client.get_pr_files(7)
|
||||
assert len(result) == 1
|
||||
assert result[0]["filename"] == "src/main.py"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/files",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_commits(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
|
||||
)
|
||||
|
||||
result = client.get_pr_commits(7)
|
||||
assert len(result) == 1
|
||||
assert result[0]["commit"]["message"] == "fix: bug"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/commits",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_reviews(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
||||
|
||||
result = client.get_pr_reviews(7)
|
||||
assert len(result) == 1
|
||||
assert result[0]["state"] == "APPROVED"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_create_issue(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"}))
|
||||
|
||||
result = client.create_issue(title="bug", body="description", labels=[1])
|
||||
assert result["id"] == 42
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues",
|
||||
json={"title": "bug", "body": "description", "labels": [1]},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_create_issue_no_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"}))
|
||||
|
||||
result = client.create_issue(title="bug", body="description")
|
||||
assert result["id"] == 43
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues",
|
||||
json={"title": "bug", "body": "description"},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_create_review_comment(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
|
||||
|
||||
result = client.create_review(7, event="COMMENT", body="Looks good")
|
||||
assert result["id"] == 42
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "COMMENT", "body": "Looks good"},
|
||||
)
|
||||
|
||||
def test_create_review_approve_maps_to_approved(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
|
||||
|
||||
result = client.create_review(7, event="APPROVE", body="Good work")
|
||||
assert result["id"] == 44
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "APPROVED", "body": "Good work"},
|
||||
)
|
||||
|
||||
def test_create_review_with_inline_comments(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
|
||||
|
||||
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
|
||||
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
|
||||
assert result["id"] == 43
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
|
||||
)
|
||||
|
||||
def test_update_repo_settings(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
|
||||
|
||||
settings = {"default_delete_branch_after_merge": True}
|
||||
result = client.update_repo_settings(settings)
|
||||
assert result["default_delete_branch_after_merge"] is True
|
||||
client._session.request.assert_called_once_with(
|
||||
"PATCH",
|
||||
"https://git.example.com/repos/owner/repo",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json=settings,
|
||||
)
|
||||
|
||||
def test_create_release(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
|
||||
|
||||
client.create_release("v1.0.0")
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/releases",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
|
||||
)
|
||||
|
||||
def test_get_release_by_tag_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
|
||||
result = client.get_release_by_tag("v1.0.0")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_get_release_by_tag_not_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
mock_resp.status_code = 404
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_release_by_tag("v9.9.9")
|
||||
assert result is None
|
||||
|
||||
def test_create_release_idempotent_existing(self) -> None:
|
||||
"""If release already exists, should return it without creating a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(return_value=existing_response)
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 42
|
||||
# Should only call GET (check), not POST (create)
|
||||
assert client._session.request.call_count == 1
|
||||
assert client._session.request.call_args[0][0] == "GET"
|
||||
|
||||
def test_create_release_idempotent_new(self) -> None:
|
||||
"""If release doesn't exist, should create it."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found_resp = MagicMock()
|
||||
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
not_found_resp.status_code = 404
|
||||
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
rate_limited = MagicMock()
|
||||
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
client._session.request = MagicMock(return_value=not_found)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 404
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestVikunjaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
assert client._base_url == "https://work.example.com"
|
||||
assert client._session.headers["Authorization"] == "Bearer tok"
|
||||
|
||||
def test_list_tasks(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"id": 1, "identifier": "DEVX-19", "project_id": VIKUNJA_PROJECT_ID}])
|
||||
)
|
||||
|
||||
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://work.example.com/tasks",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"per_page": DEFAULT_PER_PAGE},
|
||||
)
|
||||
|
||||
def test_list_project_tasks(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "DEVX-19"}]))
|
||||
|
||||
result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE)
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
|
||||
)
|
||||
|
||||
def test_get_task(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"id": 292, "identifier": "DEVX-32", "title": "Some task"})
|
||||
)
|
||||
|
||||
result = client.get_task(292)
|
||||
assert result["identifier"] == "DEVX-32"
|
||||
assert result["title"] == "Some task"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://work.example.com/tasks/292",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_post_comment(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.post_comment(42, "<p>hi</p>")
|
||||
client._session.request.assert_called_once_with(
|
||||
"PUT",
|
||||
"https://work.example.com/tasks/42/comments",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"comment": "<p>hi</p>"},
|
||||
)
|
||||
|
||||
def test_update_task(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.update_task(42, done=True)
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://work.example.com/tasks/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"done": True},
|
||||
)
|
||||
|
||||
def test_http_error_raises_api_error(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError):
|
||||
client.list_tasks()
|
||||
|
||||
def test_http_error_no_response(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = err
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Unit tests for scripts/ci/auto_merge.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.auto_merge import (
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
read_taskid,
|
||||
run_cmd,
|
||||
validate_pr_title,
|
||||
validate_pr_title_matches_vikunja,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
|
||||
# -- read_taskid --
|
||||
|
||||
|
||||
class TestReadTaskid:
|
||||
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
assert read_taskid("some-branch") == "DEVX-60"
|
||||
|
||||
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
|
||||
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("feature-branch") == ""
|
||||
|
||||
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("\n")
|
||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
||||
|
||||
|
||||
# -- extract_task_id (legacy fallback) --
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_branch(self) -> None:
|
||||
assert extract_task_id("DEVX-19-fix-bug") == "DEVX-19"
|
||||
assert extract_task_id("DEVX-123") == "DEVX-123"
|
||||
|
||||
def test_returns_empty_when_no_match(self) -> None:
|
||||
assert extract_task_id("feature-branch") == ""
|
||||
|
||||
|
||||
# -- validate_pr_title --
|
||||
|
||||
|
||||
class TestValidatePrTitle:
|
||||
def test_valid_title(self) -> None:
|
||||
validate_pr_title("DEVX-19: Add new feature", "DEVX-19")
|
||||
|
||||
def test_missing_colon(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="format"):
|
||||
validate_pr_title("DEVX-19 Add new feature", "DEVX-19")
|
||||
|
||||
def test_task_id_mismatch(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="mismatch"):
|
||||
validate_pr_title("DEVX-20: Add feature", "DEVX-19")
|
||||
|
||||
def test_no_task_id_in_title(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="format"):
|
||||
validate_pr_title("Add new feature", "DEVX-19")
|
||||
|
||||
|
||||
# -- validate_pr_title_matches_vikunja --
|
||||
|
||||
|
||||
class TestValidatePrTitleMatchesVikunja:
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_skips_when_no_token(self) -> None:
|
||||
# Should not raise — just warn
|
||||
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": ""}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "DEVX-19: fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# No .taskid file, no DEVX-N in branch name
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"])
|
||||
assert result.exit_code != 0
|
||||
assert "No task ID" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"])
|
||||
assert result.exit_code != 0
|
||||
assert "format" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = [
|
||||
APIError(405, "HEAD branch is behind master"),
|
||||
None, # Second call succeeds
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_client.merge_pr.call_count == 2
|
||||
# Should have fetched, rebased, and pushed
|
||||
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
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"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""When no conventional commit message is found in PR commits, raises."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "conventional commit" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""When rebase retry also fails, raises with helpful message."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
|
||||
mock_run.side_effect = click.ClickException("git rebase failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "rebase" in result.output.lower()
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
"""Test that the __main__ block can be executed."""
|
||||
import devx.ci.auto_merge as am
|
||||
|
||||
with open(am.__file__) as f:
|
||||
source = f.read()
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(am.__dict__)
|
||||
exec(compile(source, am.__file__, "exec"), namespace)
|
||||
# Verify main is callable
|
||||
assert callable(namespace["main"])
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Unit tests for scripts/check_test_speed.py."""
|
||||
|
||||
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,
|
||||
TEST_COMMAND,
|
||||
check_speed,
|
||||
cli,
|
||||
parse_duration,
|
||||
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_with(
|
||||
TEST_COMMAND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class TestParseDuration:
|
||||
def test_parses_valid_line(self) -> None:
|
||||
assert parse_duration("234 passed in 0.70s") == 0.70
|
||||
|
||||
def test_parses_with_warnings(self) -> None:
|
||||
assert parse_duration("293 passed, 1 warning in 0.45s") == 0.45
|
||||
|
||||
def test_parses_multiline_output(self) -> None:
|
||||
output = "some header\n234 passed in 1.23s\nfooter"
|
||||
assert parse_duration(output) == 1.23
|
||||
|
||||
def test_raises_when_no_timing_line(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
parse_duration("no timing here")
|
||||
assert "Could not parse" in str(exc.value)
|
||||
|
||||
|
||||
class TestCheckSpeed:
|
||||
def test_under_budget_passes(self) -> None:
|
||||
check_speed(1.0, 2.0) # should not raise
|
||||
|
||||
def test_exact_budget_passes(self) -> None:
|
||||
check_speed(2.0, 2.0) # should not raise
|
||||
|
||||
def test_over_budget_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
check_speed(2.1, 2.0)
|
||||
msg = str(exc.value)
|
||||
assert "too slow" in msg.lower()
|
||||
assert "2.10s" in msg
|
||||
assert "max allowed: 2.0s" in msg
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import 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")
|
||||
def test_successful_run(
|
||||
self,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("stdout\n", "stderr\n")
|
||||
mock_parse.return_value = 1.5
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "1.50s" in result.output
|
||||
assert "under 2.0s limit" in result.output
|
||||
mock_run.assert_called_once()
|
||||
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
|
||||
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
|
||||
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
def test_slow_tests_exit(
|
||||
self,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 3.0
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 1
|
||||
assert "too slow" in result.output.lower()
|
||||
|
||||
@patch("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")
|
||||
def test_custom_max_seconds(
|
||||
self,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 0.5
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--max-seconds", "1.5"])
|
||||
assert result.exit_code == 0
|
||||
mock_check.assert_called_once_with(0.5, 1.5)
|
||||
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.ci.check_translations as check_translations
|
||||
|
||||
|
||||
class TestExtractKeys:
|
||||
def test_extracts_underscore_calls(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.py"
|
||||
f.write_text('from devx.i18n import _\nprint(_("Hello world"))\n')
|
||||
keys = check_translations.extract_keys(f)
|
||||
assert "Hello world" in keys
|
||||
|
||||
def test_extracts_wrapper_calls(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.py"
|
||||
f.write_text('@_handle_errors("Update failed: {error}")\ndef foo(): pass\n')
|
||||
keys = check_translations.extract_keys(f)
|
||||
assert "Update failed: {error}" in keys
|
||||
|
||||
def test_ignores_non_string_args(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.py"
|
||||
f.write_text('x = "key"\n_(x)\n')
|
||||
keys = check_translations.extract_keys(f)
|
||||
assert keys == set()
|
||||
|
||||
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.py"
|
||||
f.write_text("def broken(:\n")
|
||||
keys = check_translations.extract_keys(f)
|
||||
assert keys == set()
|
||||
|
||||
|
||||
class TestCheckTranslationSet:
|
||||
def test_all_good(self, tmp_path: Path) -> None:
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "mod.py").write_text('_("Hello")\n')
|
||||
trans_file = tmp_path / "translations.json"
|
||||
trans_file.write_text(
|
||||
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
|
||||
)
|
||||
|
||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
||||
assert not result.errors
|
||||
assert not result.warnings
|
||||
|
||||
def test_missing_key(self, tmp_path: Path) -> None:
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "mod.py").write_text('_("Missing")\n')
|
||||
trans_file = tmp_path / "translations.json"
|
||||
trans_file.write_text(json.dumps({"Other": {"en": "Other"}}))
|
||||
|
||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
||||
assert any("Missing key" in e for e in result.errors)
|
||||
|
||||
def test_dead_key(self, tmp_path: Path) -> None:
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "mod.py").write_text('_("Used")\n')
|
||||
trans_file = tmp_path / "translations.json"
|
||||
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
|
||||
|
||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
||||
assert any("Dead key" in w for w in result.warnings)
|
||||
|
||||
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 w for w in result.warnings)
|
||||
|
||||
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_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--strict should fail if there are missing language warnings."""
|
||||
warn_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: warn_result,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, ["--strict"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should fail with exit code 1 when errors are found."""
|
||||
error_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
errors=["Missing key: 'foo'"],
|
||||
)
|
||||
ok_result = check_translations.TranslationCheckResult(
|
||||
name="devx2",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: error_result if name == "devx" else ok_result,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
|
||||
def test_passes_no_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should pass with exit code 0 and 'PASS:' message when no warnings."""
|
||||
ok_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: ok_result,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS: All translations" in result.output
|
||||
|
||||
def test_translations_flag(self, tmp_path: Path) -> None:
|
||||
"""--translations flag should check a specific file."""
|
||||
trans_file = tmp_path / "translations.json"
|
||||
trans_file.write_text(
|
||||
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
|
||||
)
|
||||
(tmp_path / "mod.py").write_text('_("Hello")\n')
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestPrintResult:
|
||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = check_translations.TranslationCheckResult(
|
||||
name="test",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
)
|
||||
check_translations.print_result(result)
|
||||
captured = capsys.readouterr()
|
||||
assert "All good!" in captured.out
|
||||
|
||||
def test_prints_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = check_translations.TranslationCheckResult(
|
||||
name="test",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
errors=["Missing key: 'foo'"],
|
||||
)
|
||||
check_translations.print_result(result)
|
||||
captured = capsys.readouterr()
|
||||
assert "ERROR" in captured.err
|
||||
|
||||
def test_prints_warnings(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = check_translations.TranslationCheckResult(
|
||||
name="test",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
check_translations.print_result(result)
|
||||
captured = capsys.readouterr()
|
||||
assert "WARN" in captured.err
|
||||
|
||||
|
||||
class TestDevxI18n:
|
||||
"""Test the devx.i18n module."""
|
||||
|
||||
def test_english_default(self) -> None:
|
||||
from devx.i18n import _
|
||||
|
||||
assert _("Running tests...") == "Running tests..."
|
||||
|
||||
def test_format_kwargs(self) -> None:
|
||||
from devx.i18n import _
|
||||
|
||||
result = _("Comparing {base}..{head} ({count} files changed)", base="a", head="b", count=5)
|
||||
assert "a..b" in result
|
||||
assert "5 files" in result
|
||||
|
||||
def test_unknown_key_returns_key(self) -> None:
|
||||
from devx.i18n import _
|
||||
|
||||
assert _("Nonexistent key 12345") == "Nonexistent key 12345"
|
||||
|
||||
def test_devx_lang_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("DEVX_LANG", "de")
|
||||
import devx.i18n
|
||||
|
||||
importlib.reload(devx.i18n)
|
||||
# "ERROR: REPO_TOKEN is not set." has a German translation
|
||||
result = devx.i18n._("ERROR: REPO_TOKEN is not set.")
|
||||
assert "FEHLER" in result
|
||||
|
||||
# Restore
|
||||
monkeypatch.delenv("DEVX_LANG", raising=False)
|
||||
importlib.reload(devx.i18n)
|
||||
|
||||
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("DEVX_LANG", "xx")
|
||||
import devx.i18n
|
||||
|
||||
importlib.reload(devx.i18n)
|
||||
result = devx.i18n._("Running tests...")
|
||||
assert result == "Running tests..." # Falls back to English
|
||||
|
||||
monkeypatch.delenv("DEVX_LANG", raising=False)
|
||||
importlib.reload(devx.i18n)
|
||||
|
||||
|
||||
class TestTranslationsFile:
|
||||
"""Test that the devx translations file is valid."""
|
||||
|
||||
def test_translations_exist(self) -> None:
|
||||
trans = Path("src/devx/translations.json")
|
||||
assert trans.exists()
|
||||
data = json.loads(trans.read_text())
|
||||
assert len(data) > 0
|
||||
|
||||
def test_all_keys_have_english(self) -> None:
|
||||
trans = Path("src/devx/translations.json")
|
||||
data = json.loads(trans.read_text())
|
||||
for key, langs in data.items():
|
||||
assert "en" in langs, f"Key {key!r} missing English translation"
|
||||
|
||||
|
||||
class TestCollectKeys:
|
||||
def test_skips_i18n_py(self, tmp_path: Path) -> None:
|
||||
"""i18n.py should be skipped when collecting keys."""
|
||||
(tmp_path / "i18n.py").write_text('_("should_not_appear")\n')
|
||||
(tmp_path / "mod.py").write_text('_("should_appear")\n')
|
||||
keys = check_translations.collect_keys(tmp_path)
|
||||
assert "should_appear" in keys
|
||||
assert "should_not_appear" not in keys
|
||||
|
||||
def test_non_default_dir_no_dynamic_keys(self, tmp_path: Path) -> None:
|
||||
"""Non-default source dirs should not include DYNAMIC_KEYS."""
|
||||
(tmp_path / "mod.py").write_text('_("mykey")\n')
|
||||
keys = check_translations.collect_keys(tmp_path)
|
||||
assert "mykey" in keys
|
||||
# Dynamic keys should NOT be present for non-default dirs
|
||||
assert "completed" not in keys
|
||||
assert "pending" not in keys
|
||||
|
||||
def test_default_dir_includes_dynamic_keys(self) -> None:
|
||||
"""The default source dir should include DYNAMIC_KEYS."""
|
||||
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
|
||||
assert "completed" in keys
|
||||
assert "pending" in keys
|
||||
assert "in_progress" in keys
|
||||
|
||||
|
||||
class TestMainWarnings:
|
||||
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should pass with exit code 0 and 'PASS with warnings' message."""
|
||||
warn_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: warn_result,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS with warnings" 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,711 @@
|
||||
"""Unit tests for devx.ci.classify_changes.
|
||||
|
||||
Tests cover:
|
||||
- Glob matching (``_glob_to_regex``, ``_matches_glob``)
|
||||
- Classifier config loading from pyproject.toml
|
||||
- ChangeClassifier with layered rules (overrides, patterns, default)
|
||||
- Tag system (orthogonal categories)
|
||||
- Backward-compatible API (is_user_facing, is_workflow_only, classify_changes)
|
||||
- Git helpers (get_changed_files, get_latest_tag, run_git)
|
||||
- CLI (main with --quiet, --check, --github-output)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.ci.classify_changes as classify_changes_mod
|
||||
from devx.ci.classify_changes import (
|
||||
DEFAULT_INFRASTRUCTURE,
|
||||
ChangeClassifier,
|
||||
ClassificationResult,
|
||||
ClassifierConfig,
|
||||
FileClassification,
|
||||
_glob_to_regex,
|
||||
_matches_glob,
|
||||
classify_changes,
|
||||
get_changed_files,
|
||||
get_latest_tag,
|
||||
has_user_facing_changes,
|
||||
is_user_facing,
|
||||
is_workflow_only,
|
||||
main,
|
||||
run_git,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Glob matching tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGlobToRegex:
|
||||
def test_double_star_matches_anything(self) -> None:
|
||||
regex = _glob_to_regex(".gitea/**")
|
||||
assert regex.match(".gitea/workflows/ci.yml")
|
||||
assert regex.match(".gitea/actionlint.yaml")
|
||||
assert regex.match(".gitea/a/b/c/d.yml")
|
||||
assert not regex.match("tests/test_foo.py")
|
||||
|
||||
def test_double_star_in_middle(self) -> None:
|
||||
"""** in the middle of a pattern matches any number of segments."""
|
||||
regex = _glob_to_regex("src/**/test_*.py")
|
||||
assert regex.match("src/test_foo.py")
|
||||
assert regex.match("src/devx/test_cli.py")
|
||||
assert regex.match("src/a/b/c/test_bar.py")
|
||||
assert not regex.match("src/cli.py")
|
||||
|
||||
def test_single_star_matches_within_segment(self) -> None:
|
||||
regex = _glob_to_regex("src/*/cli.py")
|
||||
assert regex.match("src/devx/cli.py")
|
||||
assert regex.match("src/pkg/cli.py")
|
||||
assert not regex.match("src/devx/sub/cli.py")
|
||||
|
||||
def test_question_mark_matches_single_char(self) -> None:
|
||||
regex = _glob_to_regex("file?.py")
|
||||
assert regex.match("file1.py")
|
||||
assert regex.match("fileA.py")
|
||||
assert not regex.match("file12.py")
|
||||
|
||||
def test_literal_match(self) -> None:
|
||||
regex = _glob_to_regex("Makefile")
|
||||
assert regex.match("Makefile")
|
||||
assert not regex.match("makefile")
|
||||
|
||||
def test_special_chars_escaped(self) -> None:
|
||||
regex = _glob_to_regex("file.test.py")
|
||||
assert regex.match("file.test.py")
|
||||
assert not regex.match("fileXtest.py")
|
||||
|
||||
|
||||
class TestMatchesGlob:
|
||||
def test_double_star(self) -> None:
|
||||
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/**")
|
||||
assert _matches_glob("tests/unit/test_cli.py", "tests/**")
|
||||
assert not _matches_glob("src/devx/cli.py", "tests/**")
|
||||
|
||||
def test_exact_match(self) -> None:
|
||||
assert _matches_glob("Makefile", "Makefile")
|
||||
assert _matches_glob("src/devx/__init__.py", "src/devx/__init__.py")
|
||||
assert not _matches_glob("src/devx/cli.py", "src/devx/__init__.py")
|
||||
|
||||
def test_prefix_matching(self) -> None:
|
||||
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/")
|
||||
assert _matches_glob("scripts/ci/release.py", "scripts/")
|
||||
assert not _matches_glob("tests/test_foo.py", "scripts/")
|
||||
|
||||
def test_single_star(self) -> None:
|
||||
assert _matches_glob("src/devx/cli.py", "src/devx/*.py")
|
||||
assert not _matches_glob("src/devx/sub/cli.py", "src/devx/*.py")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClassifierConfig tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClassifierConfig:
|
||||
def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
"[tool.devx.classify]\n"
|
||||
'infrastructure = ["scripts/**"]\n'
|
||||
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
|
||||
'user_facing_overrides = ["docs/important.py"]\n'
|
||||
"\n"
|
||||
"[tool.devx.classify.tags]\n"
|
||||
'ansible = ["ansible/**"]\n'
|
||||
)
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
# Project-specific path is merged with defaults
|
||||
assert "scripts/**" in config.infrastructure
|
||||
assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
|
||||
assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
|
||||
assert config.use_defaults is True
|
||||
assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
|
||||
assert config.user_facing_overrides == ["docs/important.py"]
|
||||
assert config.tags == {"ansible": ["ansible/**"]}
|
||||
|
||||
def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert config.infrastructure == [".gitea/**"]
|
||||
assert "tests/**" not in config.infrastructure # no defaults
|
||||
assert config.use_defaults is False
|
||||
|
||||
def test_from_pyproject_missing_file_returns_defaults(self) -> None:
|
||||
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
|
||||
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||
assert config.infrastructure_overrides == []
|
||||
assert config.user_facing_overrides == []
|
||||
assert config.tags == {}
|
||||
assert config.use_defaults is True
|
||||
|
||||
def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[project]\nname = "test"\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||
|
||||
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n')
|
||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||
assert "scripts/**" in config.infrastructure
|
||||
assert ".gitea/**" in config.infrastructure # merged with defaults
|
||||
assert config.infrastructure_overrides == []
|
||||
assert config.user_facing_overrides == []
|
||||
assert config.tags == {}
|
||||
|
||||
def test_defaults_are_empty_for_bare_constructor(self) -> None:
|
||||
"""ClassifierConfig() without from_pyproject has empty lists."""
|
||||
config = ClassifierConfig()
|
||||
assert config.infrastructure == []
|
||||
assert config.infrastructure_overrides == []
|
||||
assert config.user_facing_overrides == []
|
||||
assert config.tags == {}
|
||||
assert config.use_defaults is True
|
||||
|
||||
def test_default_infrastructure_is_non_empty(self) -> None:
|
||||
"""The framework ships with a curated default infrastructure list."""
|
||||
assert len(DEFAULT_INFRASTRUCTURE) > 0
|
||||
assert ".gitea/**" in DEFAULT_INFRASTRUCTURE
|
||||
assert "tests/**" in DEFAULT_INFRASTRUCTURE
|
||||
assert "docs/**" in DEFAULT_INFRASTRUCTURE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChangeClassifier tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChangeClassifier:
|
||||
def _make_classifier(self, **kwargs: object) -> ChangeClassifier:
|
||||
"""Create a classifier with explicit config (no pyproject.toml needed)."""
|
||||
config = ClassifierConfig(**kwargs) # type: ignore[arg-type]
|
||||
return ChangeClassifier(config)
|
||||
|
||||
def test_infrastructure_pattern_matches(self) -> None:
|
||||
classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"])
|
||||
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||
assert not fc.is_user_facing
|
||||
assert "infrastructure" in fc.matched_rule
|
||||
|
||||
def test_unknown_file_defaults_to_user_facing(self) -> None:
|
||||
classifier = self._make_classifier(infrastructure=[".gitea/**"])
|
||||
fc = classifier.classify_file("src/devx/cli.py")
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule is None
|
||||
assert "default" in fc.reason.lower()
|
||||
|
||||
def test_infrastructure_override(self) -> None:
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**"],
|
||||
infrastructure_overrides=["src/devx/__init__.py"],
|
||||
)
|
||||
fc = classifier.classify_file("src/devx/__init__.py")
|
||||
assert not fc.is_user_facing
|
||||
assert fc.matched_rule == "infrastructure_overrides"
|
||||
|
||||
def test_user_facing_override_beats_infrastructure(self) -> None:
|
||||
"""User-facing overrides have highest priority (safety)."""
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=["tests/**"],
|
||||
user_facing_overrides=["tests/test_public_api.py"],
|
||||
)
|
||||
fc = classifier.classify_file("tests/test_public_api.py")
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_beats_infrastructure_override(self) -> None:
|
||||
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**"],
|
||||
infrastructure_overrides=["src/devx/__init__.py"],
|
||||
user_facing_overrides=["src/devx/__init__.py"],
|
||||
)
|
||||
fc = classifier.classify_file("src/devx/__init__.py")
|
||||
assert fc.is_user_facing
|
||||
|
||||
def test_tags_are_computed(self) -> None:
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**", ".ansible-lint"], "docs": ["docs/**"]},
|
||||
)
|
||||
fc = classifier.classify_file("ansible/tasks/main.yml")
|
||||
assert "ansible" in fc.tags
|
||||
assert "docs" not in fc.tags
|
||||
|
||||
def test_tags_orthogonal_to_classification(self) -> None:
|
||||
"""A file can be infrastructure AND tagged."""
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**", "docs/**"],
|
||||
tags={"docs": ["docs/**"]},
|
||||
)
|
||||
fc = classifier.classify_file("docs/index.md")
|
||||
assert not fc.is_user_facing # infrastructure
|
||||
assert "docs" in fc.tags # also tagged
|
||||
|
||||
def test_classify_multiple_files(self) -> None:
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**", "tests/**"],
|
||||
infrastructure_overrides=["src/devx/__init__.py"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
files = [
|
||||
"src/devx/cli.py",
|
||||
".gitea/workflows/ci.yml",
|
||||
"src/devx/__init__.py",
|
||||
"ansible/tasks/main.yml",
|
||||
"tests/test_foo.py",
|
||||
]
|
||||
result = classifier.classify(files)
|
||||
assert "src/devx/cli.py" in result.user_facing
|
||||
assert "ansible/tasks/main.yml" in result.user_facing
|
||||
assert ".gitea/workflows/ci.yml" in result.infrastructure
|
||||
assert "src/devx/__init__.py" in result.infrastructure
|
||||
assert "tests/test_foo.py" in result.infrastructure
|
||||
assert result.has_user_facing
|
||||
assert result.has_tag("ansible")
|
||||
assert "ansible/tasks/main.yml" in result.tags["ansible"]
|
||||
|
||||
def test_classify_empty(self) -> None:
|
||||
classifier = self._make_classifier(infrastructure=[".gitea/**"])
|
||||
result = classifier.classify([])
|
||||
assert not result.has_user_facing
|
||||
assert result.user_facing == []
|
||||
assert result.infrastructure == []
|
||||
|
||||
def test_reason_is_human_readable(self) -> None:
|
||||
classifier = self._make_classifier(infrastructure=[".gitea/**"])
|
||||
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||
assert ".gitea/**" in fc.reason
|
||||
fc2 = classifier.classify_file("src/devx/cli.py")
|
||||
assert "default" in fc2.reason.lower() or "user-facing" in fc2.reason.lower()
|
||||
|
||||
|
||||
class TestClassificationResult:
|
||||
def test_has_user_facing(self) -> None:
|
||||
result = ClassificationResult(user_facing=["src/cli.py"])
|
||||
assert result.has_user_facing
|
||||
|
||||
def test_has_user_facing_empty(self) -> None:
|
||||
result = ClassificationResult()
|
||||
assert not result.has_user_facing
|
||||
|
||||
def test_has_tag(self) -> None:
|
||||
result = ClassificationResult(tags={"ansible": ["ansible/tasks/main.yml"]})
|
||||
assert result.has_tag("ansible")
|
||||
assert not result.has_tag("docs")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatible API tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackwardCompatibleAPI:
|
||||
def test_is_workflow_only_with_config(self) -> None:
|
||||
"""is_workflow_only uses the config-driven classifier by default."""
|
||||
with patch.object(classify_changes_mod, "_get_classifier") as mock:
|
||||
classifier = MagicMock()
|
||||
classifier.classify_file.return_value = FileClassification(
|
||||
path=".gitea/workflows/ci.yml",
|
||||
is_user_facing=False,
|
||||
reason="test",
|
||||
matched_rule="infrastructure: .gitea/**",
|
||||
)
|
||||
mock.return_value = classifier
|
||||
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
||||
|
||||
def test_is_user_facing_with_config(self) -> None:
|
||||
with patch.object(classify_changes_mod, "_get_classifier") as mock:
|
||||
classifier = MagicMock()
|
||||
classifier.classify_file.return_value = FileClassification(
|
||||
path="src/devx/cli.py",
|
||||
is_user_facing=True,
|
||||
reason="test",
|
||||
matched_rule=None,
|
||||
)
|
||||
mock.return_value = classifier
|
||||
assert is_user_facing("src/devx/cli.py") is True
|
||||
|
||||
def test_legacy_patterns_mode(self) -> None:
|
||||
"""is_workflow_only with explicit patterns uses legacy prefix matching."""
|
||||
patterns = frozenset([".gitea/", "tests/"])
|
||||
assert is_workflow_only(".gitea/workflows/ci.yml", patterns) is True
|
||||
assert is_workflow_only("tests/test_foo.py", patterns) is True
|
||||
assert is_workflow_only("src/devx/cli.py", patterns) is False
|
||||
|
||||
def test_classify_changes_with_config(self) -> None:
|
||||
with patch.object(classify_changes_mod, "_get_classifier") as mock:
|
||||
classifier = MagicMock()
|
||||
classifier.classify.return_value = ClassificationResult(
|
||||
user_facing=["src/devx/cli.py"],
|
||||
infrastructure=[".gitea/workflows/ci.yml"],
|
||||
)
|
||||
mock.return_value = classifier
|
||||
result = classify_changes(["src/devx/cli.py", ".gitea/workflows/ci.yml"])
|
||||
assert "src/devx/cli.py" in result["user_facing"]
|
||||
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
|
||||
|
||||
def test_classify_changes_legacy_mode(self) -> None:
|
||||
patterns = frozenset([".gitea/", "tests/"])
|
||||
result = classify_changes([".gitea/ci.yml", "src/cli.py"], patterns)
|
||||
assert ".gitea/ci.yml" in result["workflow_only"]
|
||||
assert "src/cli.py" in result["user_facing"]
|
||||
|
||||
def test_has_user_facing_changes_with_config(self) -> None:
|
||||
with (
|
||||
patch.object(classify_changes_mod, "get_changed_files", return_value=["src/devx/cli.py"]),
|
||||
patch.object(classify_changes_mod, "_get_classifier") as mock,
|
||||
):
|
||||
classifier = MagicMock()
|
||||
classifier.classify.return_value = ClassificationResult(
|
||||
user_facing=["src/devx/cli.py"],
|
||||
)
|
||||
mock.return_value = classifier
|
||||
assert has_user_facing_changes("v0.1.0", "HEAD") is True
|
||||
|
||||
def test_has_user_facing_changes_legacy(self) -> None:
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[".gitea/ci.yml"]):
|
||||
patterns = frozenset([".gitea/"])
|
||||
assert has_user_facing_changes("v0.1.0", "HEAD", patterns) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git helper tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetChangedFiles:
|
||||
@patch("devx.ci.classify_changes.run_git")
|
||||
def test_returns_file_list(self, mock_run_git: MagicMock) -> None:
|
||||
mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md"
|
||||
result = get_changed_files("v0.1.0", "HEAD")
|
||||
assert result == ["file1.py", "file2.py", "file3.md"]
|
||||
|
||||
@patch("devx.ci.classify_changes.run_git")
|
||||
def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None:
|
||||
mock_run_git.return_value = ""
|
||||
result = get_changed_files("v0.1.0", "HEAD")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetLatestTag:
|
||||
@patch("subprocess.run")
|
||||
def test_returns_tag(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="")
|
||||
assert get_latest_tag() == "v0.3.0"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
assert get_latest_tag() == ""
|
||||
|
||||
|
||||
class TestRunGit:
|
||||
@patch("devx.ci.classify_changes.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="")
|
||||
result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"])
|
||||
assert result == "file1.py"
|
||||
|
||||
@patch("devx.ci.classify_changes.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_git(["git", "bad-command"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 2
|
||||
assert "no release needed" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py", "docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_default_mode_displays_tags(
|
||||
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
|
||||
) -> None:
|
||||
"""Default mode shows tag files when tags are configured."""
|
||||
mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Ansible files" in result.output
|
||||
assert "ansible/tasks/main.yml" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No tags found" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No changes" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible"])
|
||||
assert result.exit_code == 0
|
||||
assert "Ansible changes detected" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py"]
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "nonexistent"])
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown check category" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/devx/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing"])
|
||||
assert result.exit_code == 0
|
||||
assert "User-facing changes detected" in result.output
|
||||
|
||||
|
||||
class TestGithubOutput:
|
||||
def _make_classifier_with_ansible(self) -> ChangeClassifier:
|
||||
return ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**", "AGENTS.md"],
|
||||
tags={"ansible": ["ansible/**"]},
|
||||
)
|
||||
)
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_no_tags_outputs_all_tags_true(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When no tags exist, only user-facing-changed is written."""
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={},
|
||||
)
|
||||
)
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
# No tag outputs since no tags are configured
|
||||
assert "ansible-changed" not in content
|
||||
@@ -0,0 +1,295 @@
|
||||
"""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_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 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,91 @@
|
||||
"""Unit tests for config module constants."""
|
||||
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
GITEA_API_URL,
|
||||
MAX_RETRIES,
|
||||
REPO_OWNER,
|
||||
RETRY_BACKOFF_BASE,
|
||||
RETRY_STATUS_CODES,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
VIKUNJA_API_URL,
|
||||
)
|
||||
|
||||
|
||||
class TestConfigConstants:
|
||||
def test_api_urls(self) -> None:
|
||||
assert "api/v1" in GITEA_API_URL
|
||||
assert "api/v1" in VIKUNJA_API_URL
|
||||
|
||||
def test_project_ids(self, monkeypatch: object) -> None:
|
||||
"""VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set."""
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
import importlib
|
||||
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||
# Restore module state
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_timeouts(self) -> None:
|
||||
assert DEFAULT_TIMEOUT == 30
|
||||
assert DEFAULT_PER_PAGE == 50
|
||||
|
||||
def test_owner(self) -> None:
|
||||
assert REPO_OWNER == "oblachno-oss"
|
||||
|
||||
def test_task_prefix(self) -> None:
|
||||
assert TASK_PREFIX == "DEVX"
|
||||
|
||||
def test_task_id_re(self) -> None:
|
||||
assert TASK_ID_RE.search("DEVX-1")
|
||||
assert TASK_ID_RE.search("DEVX-123")
|
||||
assert not TASK_ID_RE.search("DEVX-")
|
||||
assert not TASK_ID_RE.search("other text")
|
||||
|
||||
def test_conventional_re(self) -> None:
|
||||
assert CONVENTIONAL_RE.match("feat: add feature")
|
||||
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
|
||||
assert not CONVENTIONAL_RE.match("random message")
|
||||
assert not CONVENTIONAL_RE.match("feat:")
|
||||
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
|
||||
|
||||
def test_retry_config(self) -> None:
|
||||
assert MAX_RETRIES == 3
|
||||
assert RETRY_BACKOFF_BASE == 2
|
||||
assert 429 in RETRY_STATUS_CODES
|
||||
assert 500 in RETRY_STATUS_CODES
|
||||
assert 502 in RETRY_STATUS_CODES
|
||||
assert 503 in RETRY_STATUS_CODES
|
||||
assert 504 in RETRY_STATUS_CODES
|
||||
|
||||
def test_env_var_override(self, monkeypatch: object) -> None:
|
||||
"""Test that env vars override defaults at import time."""
|
||||
# We can't easily re-import the module, but we can verify
|
||||
# the constants respect env vars by checking the module source.
|
||||
import devx.config as cfg
|
||||
|
||||
assert cfg.GITEA_API_URL # always non-empty
|
||||
assert cfg.VIKUNJA_API_URL # always non-empty
|
||||
|
||||
|
||||
class TestTaskPrefixOverride:
|
||||
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
||||
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
||||
import importlib
|
||||
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "INFRA"
|
||||
assert cfg.TASK_ID_RE.search("INFRA-42")
|
||||
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
||||
# Restore
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
importlib.reload(cfg)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Unit tests for devx.tools.configure_repo."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.exceptions import APIError
|
||||
from devx.tools.configure_repo import (
|
||||
_default_branch_protection_config,
|
||||
_default_repo_settings_config,
|
||||
_handle_http_error,
|
||||
configure_repo,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleHttpError:
|
||||
def test_forbidden_raises_click_exception(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Forbidden"):
|
||||
_handle_http_error(APIError(403, "Forbidden"))
|
||||
|
||||
def test_other_error_raises_click_exception(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="HTTP error"):
|
||||
_handle_http_error(APIError(500, "Server error"))
|
||||
|
||||
|
||||
class TestDefaultConfigs:
|
||||
def test_default_branch_protection_config(self) -> None:
|
||||
config = _default_branch_protection_config()
|
||||
assert config["branch_name"] == "master"
|
||||
assert config["enable_push"] is True
|
||||
assert config["enable_push_whitelist"] is False
|
||||
assert config["required_approvals"] == 0
|
||||
assert isinstance(config["status_check_contexts"], list)
|
||||
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
||||
|
||||
def test_default_repo_settings_config(self) -> None:
|
||||
config = _default_repo_settings_config()
|
||||
assert config["default_delete_branch_after_merge"] is True
|
||||
|
||||
def test_status_checks_from_env(self) -> None:
|
||||
with patch.dict("os.environ", {"DEVX_STATUS_CHECKS": "check1, check2, check3"}):
|
||||
config = _default_branch_protection_config()
|
||||
assert config["status_check_contexts"] == ["check1", "check2", "check3"]
|
||||
|
||||
|
||||
class TestConfigureRepo:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
configure_repo(token="tok", owner="owner", repo="repo")
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with pytest.raises(click.ClickException, match="Forbidden"):
|
||||
configure_repo(token="tok", owner="owner", repo="repo")
|
||||
|
||||
def test_configure_repo_no_token(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
||||
configure_repo(token="", owner="owner", repo="repo")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
custom_bp = {
|
||||
"branch_name": "develop",
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": True,
|
||||
"push_whitelist_usernames": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": ["CI / quality (pull_request)"],
|
||||
"required_approvals": 2,
|
||||
}
|
||||
custom_rs = {"default_delete_branch_after_merge": False}
|
||||
configure_repo(
|
||||
token="tok",
|
||||
owner="owner",
|
||||
repo="repo",
|
||||
branch="develop",
|
||||
branch_protection_config=custom_bp,
|
||||
repo_settings_config=custom_rs,
|
||||
)
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
|
||||
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
|
||||
assert result.exit_code == 0
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "myrepo"])
|
||||
assert result.exit_code != 0
|
||||
assert "Forbidden" in result.output
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_main_no_token(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "myrepo"])
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
def test_main_no_repo(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Repository name not specified" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "myrepo", "--branch", "develop"])
|
||||
assert result.exit_code == 0
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
args = mock_client.ensure_branch_protection.call_args
|
||||
assert args[0][0] == "develop"
|
||||
@@ -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,210 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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 (
|
||||
MOLECULE_ROOT,
|
||||
PLATFORMS,
|
||||
TestPair,
|
||||
build_pairs,
|
||||
cli,
|
||||
discover_scenarios,
|
||||
distribute,
|
||||
pairs_for_runner,
|
||||
)
|
||||
|
||||
|
||||
class TestDiscoverScenarios:
|
||||
def test_discovers_scenarios(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "molecule"
|
||||
(root / "default").mkdir(parents=True)
|
||||
(root / "binary").mkdir(parents=True)
|
||||
(root / "common").mkdir(parents=True)
|
||||
(root / "_shared").mkdir(parents=True)
|
||||
result = discover_scenarios(root)
|
||||
assert result == ["binary", "default"]
|
||||
|
||||
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
discover_scenarios(tmp_path / "nonexistent")
|
||||
assert "not found" in str(exc.value)
|
||||
|
||||
def test_default_root_constant(self) -> None:
|
||||
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
|
||||
|
||||
|
||||
class TestPairEncoding:
|
||||
def test_encode_roundtrip(self) -> None:
|
||||
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
|
||||
encoded = pair.encode()
|
||||
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
|
||||
decoded = TestPair.decode(encoded)
|
||||
assert decoded.scenario == "default"
|
||||
assert decoded.platform["name"] == "ubuntu-2204"
|
||||
assert decoded.platform["image"] == "ubuntu:22.04"
|
||||
assert decoded.platform["command"] == ""
|
||||
|
||||
def test_encode_with_command(self) -> None:
|
||||
pair = TestPair(
|
||||
"default",
|
||||
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
)
|
||||
encoded = pair.encode()
|
||||
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
|
||||
decoded = TestPair.decode(encoded)
|
||||
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
|
||||
|
||||
|
||||
class TestBuildPairs:
|
||||
def test_cross_product(self) -> None:
|
||||
scenarios = ["a", "b"]
|
||||
platforms = [
|
||||
{"name": "p1", "image": "img1", "command": ""},
|
||||
{"name": "p2", "image": "img2", "command": ""},
|
||||
]
|
||||
pairs = build_pairs(scenarios, platforms)
|
||||
assert len(pairs) == 4
|
||||
assert pairs[0].scenario == "a"
|
||||
assert pairs[0].platform["name"] == "p1"
|
||||
assert pairs[1].scenario == "a"
|
||||
assert pairs[1].platform["name"] == "p2"
|
||||
assert pairs[2].scenario == "b"
|
||||
assert pairs[2].platform["name"] == "p1"
|
||||
assert pairs[3].scenario == "b"
|
||||
assert pairs[3].platform["name"] == "p2"
|
||||
|
||||
def test_default_platforms(self) -> None:
|
||||
pairs = build_pairs(["default"])
|
||||
assert len(pairs) == len(PLATFORMS)
|
||||
assert all(p.scenario == "default" for p in pairs)
|
||||
|
||||
|
||||
class TestDistribute:
|
||||
def test_even_split(self) -> None:
|
||||
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
|
||||
groups = distribute(pairs, 3)
|
||||
assert len(groups) == 3
|
||||
assert len(groups[0]) == 2
|
||||
assert len(groups[1]) == 2
|
||||
assert len(groups[2]) == 2
|
||||
|
||||
def test_uneven_split(self) -> None:
|
||||
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
|
||||
groups = distribute(pairs, 3)
|
||||
assert len(groups[0]) == 2
|
||||
assert len(groups[1]) == 2
|
||||
assert len(groups[2]) == 1
|
||||
|
||||
def test_more_runners_than_pairs(self) -> None:
|
||||
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
|
||||
groups = distribute(pairs, 5)
|
||||
assert len(groups) == 5
|
||||
assert len(groups[0]) == 1
|
||||
assert all(len(g) == 0 for g in groups[1:])
|
||||
|
||||
def test_empty_pairs(self) -> None:
|
||||
groups = distribute([], 3)
|
||||
assert groups == [[], [], []]
|
||||
|
||||
|
||||
class TestPairsForRunner:
|
||||
def test_returns_correct_subset(self) -> None:
|
||||
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
|
||||
assert len(pairs_for_runner(pairs, 0, 3)) == 2
|
||||
assert len(pairs_for_runner(pairs, 1, 3)) == 2
|
||||
assert len(pairs_for_runner(pairs, 2, 3)) == 2
|
||||
|
||||
def test_out_of_range_raises(self) -> None:
|
||||
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
pairs_for_runner(pairs, 5, 3)
|
||||
assert "out of range" in str(exc.value)
|
||||
|
||||
def test_negative_index_raises(self) -> None:
|
||||
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
pairs_for_runner(pairs, -1, 3)
|
||||
assert "out of range" in str(exc.value)
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_list_flag(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
from 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-2204" in result.output
|
||||
assert "ubuntu-2404" in result.output
|
||||
assert "debian-12" in result.output
|
||||
assert "archlinux" in result.output
|
||||
|
||||
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
from 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-2204" in result.output
|
||||
|
||||
|
||||
class TestGithubEnv:
|
||||
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("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
|
||||
|
||||
|
||||
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"])
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit tests for devx.ci.doc_coverage."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.doc_coverage import (
|
||||
check_command_documented,
|
||||
check_module_documented,
|
||||
extract_cli_commands,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractCliCommands:
|
||||
def test_extracts_commands(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
# devx CLI has commands under ci, tools, and molecule groups
|
||||
assert "auto-merge" in commands
|
||||
assert "release" in commands
|
||||
assert "publish" in commands
|
||||
assert "setup" in commands
|
||||
assert "install-tools" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
assert isinstance(commands, list)
|
||||
assert len(commands) > 0
|
||||
|
||||
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Returns empty list when CLI file doesn't exist."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
|
||||
commands = extract_cli_commands()
|
||||
assert commands == []
|
||||
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When a command decorator has no explicit name, falls back to the def name."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
assert "my_command" in commands
|
||||
|
||||
|
||||
class TestCheckCommandDocumented:
|
||||
def test_finds_command_in_heading(self) -> None:
|
||||
content = "## auto-merge\n\nAuto-merge PR."
|
||||
assert check_command_documented("auto-merge", content) is True
|
||||
|
||||
def test_finds_command_in_code_block(self) -> None:
|
||||
content = "```bash\ndevx ci release --dry-run\n```"
|
||||
assert check_command_documented("release", content) is True
|
||||
|
||||
def test_finds_command_with_devx_prefix(self) -> None:
|
||||
content = "Use `devx tools setup` to install."
|
||||
assert check_command_documented("setup", content) is True
|
||||
|
||||
def test_missing_command(self) -> None:
|
||||
content = "## Other stuff\n\nNo commands here."
|
||||
assert check_command_documented("release", content) is False
|
||||
|
||||
|
||||
class TestCheckModuleDocumented:
|
||||
def test_finds_module(self) -> None:
|
||||
content = "The cli.py module handles..."
|
||||
assert check_module_documented("cli.py", content) is True
|
||||
|
||||
def test_missing_module(self) -> None:
|
||||
content = "No modules mentioned."
|
||||
assert check_module_documented("cli.py", content) is False
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_all_present(self, tmp_path: Path) -> None:
|
||||
"""When all docs exist and cover all commands/modules, exit 0."""
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
# Get actual commands from the CLI
|
||||
commands = extract_cli_commands()
|
||||
# Write cli-commands.md with all commands
|
||||
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
|
||||
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
||||
# Write architecture.md with all modules
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
def test_missing_docs_fail(self, tmp_path: Path) -> None:
|
||||
"""When docs are missing and --fail-on-missing is set, exit 1."""
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
assert "MISSING" in result.output
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.generate_badges import (
|
||||
COLOR_HEX,
|
||||
cli,
|
||||
coverage_color,
|
||||
doc_coverage_color,
|
||||
extract_coverage,
|
||||
extract_doc_coverage,
|
||||
extract_test_count,
|
||||
generate_badges,
|
||||
make_badge,
|
||||
read_version,
|
||||
render_svg,
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
@patch("devx.tools.generate_badges.subprocess.run")
|
||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
|
||||
rc, out, err = run_command(["echo", "hello"])
|
||||
assert rc == 0
|
||||
assert out == "out"
|
||||
assert err == "err"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestMakeBadge:
|
||||
def test_creates_valid_badge_dict(self) -> None:
|
||||
badge = make_badge("coverage", "100%", "brightgreen")
|
||||
assert badge == {
|
||||
"schemaVersion": 1,
|
||||
"label": "coverage",
|
||||
"message": "100%",
|
||||
"color": "brightgreen",
|
||||
}
|
||||
|
||||
|
||||
class TestRenderSvg:
|
||||
def test_generates_valid_svg(self) -> None:
|
||||
svg = render_svg("coverage", "100%", "brightgreen")
|
||||
assert svg.startswith("<svg")
|
||||
assert svg.endswith("</svg>\n")
|
||||
assert "coverage" in svg
|
||||
assert "100%" in svg
|
||||
assert COLOR_HEX["brightgreen"] in svg
|
||||
|
||||
def test_uses_color_hex_for_known_colors(self) -> None:
|
||||
svg = render_svg("tests", "573 passing", "brightgreen")
|
||||
assert "#4c1" in svg
|
||||
|
||||
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
|
||||
svg = render_svg("label", "msg", "#abc123")
|
||||
assert "#abc123" in svg
|
||||
|
||||
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
|
||||
svg = render_svg("label", "msg", "nonexistent")
|
||||
assert "#9f9f9f" in svg
|
||||
|
||||
def test_escapes_xml_special_chars(self) -> None:
|
||||
svg = render_svg("label", "<script>", "red")
|
||||
assert "<script>" not in svg
|
||||
assert "<script>" in svg
|
||||
|
||||
def test_has_correct_dimensions(self) -> None:
|
||||
svg = render_svg("coverage", "100%", "brightgreen")
|
||||
assert 'width="' in svg
|
||||
assert 'height="20"' in svg
|
||||
|
||||
|
||||
class TestExtractCoverage:
|
||||
def test_extracts_from_total_line(self) -> None:
|
||||
output = (
|
||||
"src/devx/cli.py 118 0 100%\n"
|
||||
"TOTAL 1798 0 100.00%\n"
|
||||
)
|
||||
assert extract_coverage(output) == 100.0
|
||||
|
||||
def test_extracts_partial_coverage(self) -> None:
|
||||
output = "TOTAL 100 20 80.00%\n"
|
||||
assert extract_coverage(output) == 80.0
|
||||
|
||||
def test_extracts_without_decimal(self) -> None:
|
||||
output = "TOTAL 1900 0 100%\n"
|
||||
assert extract_coverage(output) == 100.0
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_coverage("no coverage here") is None
|
||||
|
||||
|
||||
class TestExtractTestCount:
|
||||
def test_extracts_passed_count(self) -> None:
|
||||
assert extract_test_count("543 passed in 2.32s") == 543
|
||||
|
||||
def test_extracts_with_warnings(self) -> None:
|
||||
assert extract_test_count("543 passed, 1 warning in 2.32s") == 543
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_test_count("no tests here") is None
|
||||
|
||||
|
||||
class TestExtractDocCoverage:
|
||||
def test_extracts_percentage(self) -> None:
|
||||
output = "\nDoc coverage: 20/20 (100%)"
|
||||
assert extract_doc_coverage(output) == 100
|
||||
|
||||
def test_extracts_partial(self) -> None:
|
||||
output = "\nDoc coverage: 18/20 (90%)"
|
||||
assert extract_doc_coverage(output) == 90
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_doc_coverage("no doc coverage here") is None
|
||||
|
||||
|
||||
class TestCoverageColor:
|
||||
def test_100_is_brightgreen(self) -> None:
|
||||
assert coverage_color(100.0) == "brightgreen"
|
||||
|
||||
def test_90_is_green(self) -> None:
|
||||
assert coverage_color(90.0) == "green"
|
||||
|
||||
def test_80_is_yellowgreen(self) -> None:
|
||||
assert coverage_color(80.0) == "yellowgreen"
|
||||
|
||||
def test_70_is_yellow(self) -> None:
|
||||
assert coverage_color(70.0) == "yellow"
|
||||
|
||||
def test_60_is_orange(self) -> None:
|
||||
assert coverage_color(60.0) == "orange"
|
||||
|
||||
def test_below_60_is_red(self) -> None:
|
||||
assert coverage_color(50.0) == "red"
|
||||
|
||||
|
||||
class TestDocCoverageColor:
|
||||
def test_100_is_brightgreen(self) -> None:
|
||||
assert doc_coverage_color(100) == "brightgreen"
|
||||
|
||||
def test_90_is_green(self) -> None:
|
||||
assert doc_coverage_color(90) == "green"
|
||||
|
||||
def test_80_is_yellowgreen(self) -> None:
|
||||
assert doc_coverage_color(80) == "yellowgreen"
|
||||
|
||||
def test_70_is_yellow(self) -> None:
|
||||
assert doc_coverage_color(70) == "yellow"
|
||||
|
||||
def test_below_70_is_orange(self) -> None:
|
||||
assert doc_coverage_color(60) == "orange"
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "0.5.0"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = "no version here\n"
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
|
||||
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
|
||||
assert read_version() == "unknown"
|
||||
|
||||
|
||||
class TestFindPackageInit:
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_src_dir(self, mock_root: MagicMock) -> None:
|
||||
"""Returns None when src/ directory doesn't exist."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
mock_src = MagicMock()
|
||||
mock_src.exists.return_value = False
|
||||
mock_root.__truediv__ = MagicMock(return_value=mock_src)
|
||||
assert _find_package_init() is None
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns None when no __init__.py has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "__init__.py").write_text("# no version here\n")
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
assert _find_package_init() is None
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns the __init__.py that has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
pkg_dir = src_dir / "mypkg"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(src_dir / "__init__.py").write_text("# no version\n")
|
||||
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
result = _find_package_init()
|
||||
assert result is not None
|
||||
assert "__version__" in result.read_text()
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Handles OSError when reading init files."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
init_file = src_dir / "__init__.py"
|
||||
init_file.write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
# Patch Path.read_text to raise OSError
|
||||
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
|
||||
result = _find_package_init()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_generates_all_badge_files(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
|
||||
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
|
||||
assert set(badges.keys()) == expected
|
||||
|
||||
# Verify SVG files were written
|
||||
for name in expected:
|
||||
svg_file = tmp_path / f"{name}.svg"
|
||||
assert svg_file.exists()
|
||||
content = svg_file.read_text()
|
||||
assert content.startswith("<svg")
|
||||
assert "</svg>" in content
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_pass_when_all_lint_passes(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "A"
|
||||
assert badges["quality"]["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_fails_when_lint_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.side_effect = [
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(1, "error", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
]
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "F"
|
||||
assert badges["quality"]["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
|
||||
def test_badges_show_unknown_when_extraction_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (1, "garbled output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["coverage"]["message"] == "unknown"
|
||||
assert badges["coverage"]["color"] == "red"
|
||||
assert badges["tests"]["message"] == "unknown"
|
||||
assert badges["tests"]["color"] == "red"
|
||||
assert badges["docs"]["message"] == "unknown"
|
||||
assert badges["docs"]["color"] == "red"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.generate_badges.generate_badges")
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
|
||||
mock_gen.return_value = {
|
||||
"coverage": make_badge("coverage", "100%", "brightgreen"),
|
||||
"tests": make_badge("tests", "573 passing", "brightgreen"),
|
||||
}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "Generating badges" in result.output
|
||||
assert "Generated 2 badges" in result.output
|
||||
mock_gen.assert_called_once_with(tmp_path)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.tools.generate_badges as gb
|
||||
|
||||
with patch.object(gb, "cli") as mock_cli:
|
||||
with patch.object(gb, "__name__", "__main__"):
|
||||
gb.cli([])
|
||||
mock_cli.assert_called_once_with([])
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Unit tests for scripts/gitea_cli.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
|
||||
|
||||
|
||||
class TestExtractIssueNumber:
|
||||
def test_extract_from_created_issue(self) -> None:
|
||||
assert _extract_issue_number("Created issue #42: Bug title") == 42
|
||||
|
||||
def test_extract_no_hash(self) -> None:
|
||||
assert _extract_issue_number("No issue number here") == 0
|
||||
|
||||
def test_extract_multiple_hashes(self) -> None:
|
||||
assert _extract_issue_number("Issue #5 and PR #10") == 5
|
||||
|
||||
def test_extract_with_colon(self) -> None:
|
||||
assert _extract_issue_number("Created issue #7: title") == 7
|
||||
|
||||
def test_extract_invalid_number(self) -> None:
|
||||
assert _extract_issue_number("Issue #abc: title") == 0
|
||||
|
||||
|
||||
class TestExtractPrNumber:
|
||||
def test_extract_from_created_pr(self) -> None:
|
||||
assert _extract_pr_number("Created PR #128: Feature") == 128
|
||||
|
||||
def test_extract_no_number(self) -> None:
|
||||
assert _extract_pr_number("No PR number") == 0
|
||||
|
||||
|
||||
class TestTeaCLIInit:
|
||||
def test_auto_detect_tea(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
cli = TeaCLI()
|
||||
assert cli._tea == "/usr/bin/tea"
|
||||
|
||||
def test_explicit_tea_bin(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/custom/tea")
|
||||
assert cli._tea == "/custom/tea"
|
||||
|
||||
def test_fallback_to_tea(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
cli = TeaCLI()
|
||||
assert cli._tea == "tea"
|
||||
|
||||
def test_with_repo(self) -> None:
|
||||
cli = TeaCLI(repo="owner/repo")
|
||||
assert cli._repo == "owner/repo"
|
||||
|
||||
|
||||
class TestTeaCLIRun:
|
||||
def test_run_success_json(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
output = cli._run(["labels", "list"])
|
||||
assert output == '[{"id": 1}]'
|
||||
|
||||
def test_run_success_raw(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
output = cli._run_raw(["issues", "create"])
|
||||
assert output == "Created issue #42"
|
||||
|
||||
def test_run_failure_raises(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_includes_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli._run(["labels", "list"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" in cmd
|
||||
assert "json" in cmd
|
||||
|
||||
def test_run_raw_no_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli._run_raw(["whoami"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" not in cmd
|
||||
|
||||
|
||||
class TestRepoArg:
|
||||
def test_with_repo_arg(self) -> None:
|
||||
cli = TeaCLI(repo="owner/repo")
|
||||
assert cli._repo_arg() == ["--repo", "owner/repo"]
|
||||
|
||||
def test_with_explicit_repo(self) -> None:
|
||||
cli = TeaCLI()
|
||||
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
|
||||
|
||||
def test_without_repo(self) -> None:
|
||||
cli = TeaCLI()
|
||||
assert cli._repo_arg() == []
|
||||
|
||||
def test_explicit_overrides_default(self) -> None:
|
||||
cli = TeaCLI(repo="default/repo")
|
||||
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
|
||||
|
||||
|
||||
class TestCreateIssue:
|
||||
def test_create_issue_basic(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
|
||||
assert issue["index"] == 42
|
||||
assert issue["title"] == "Bug"
|
||||
|
||||
def test_create_issue_with_labels(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
|
||||
assert issue["index"] == 5
|
||||
|
||||
|
||||
class TestListLabels:
|
||||
def test_list_labels_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
labels = cli.list_labels("owner/repo")
|
||||
assert len(labels) == 2
|
||||
assert labels[0]["name"] == "bug"
|
||||
|
||||
def test_list_labels_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
labels = cli.list_labels("owner/repo")
|
||||
assert labels == []
|
||||
|
||||
|
||||
class TestCreateLabel:
|
||||
def test_create_label_full(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
|
||||
assert label["name"] == "bug"
|
||||
assert label["color"] == "ff0000"
|
||||
|
||||
def test_create_label_name_only(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
label = cli.create_label("owner/repo", name="wip")
|
||||
assert label["name"] == "wip"
|
||||
assert label["color"] == ""
|
||||
|
||||
|
||||
class TestAddLabel:
|
||||
def test_add_label_single(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--add-labels" in cmd
|
||||
assert "ready-to-merge" in cmd
|
||||
assert "42" in cmd
|
||||
|
||||
def test_add_label_multiple(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.add_label("owner/repo", 42, ["bug", "urgent"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--add-labels" in cmd
|
||||
|
||||
def test_add_label_empty_list(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
cli.add_label("owner/repo", 42, [])
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestCreatePR:
|
||||
def test_create_pr_basic(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
|
||||
assert pr["index"] == 128
|
||||
|
||||
def test_create_pr_with_body(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--body" in cmd
|
||||
assert "Description" in cmd
|
||||
|
||||
|
||||
class TestMergePR:
|
||||
def test_merge_pr_squash(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.merge_pr("owner/repo", 42, style="squash")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--style" in cmd
|
||||
assert "squash" in cmd
|
||||
assert "42" in cmd
|
||||
|
||||
def test_merge_pr_default_style(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.merge_pr("owner/repo", 42)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "squash" in cmd
|
||||
|
||||
|
||||
class TestReviewPR:
|
||||
def test_review_approve(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--approve" in cmd
|
||||
assert "--comment" in cmd
|
||||
|
||||
def test_review_reject(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--reject" in cmd
|
||||
|
||||
def test_review_comment(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--approve" not in cmd
|
||||
assert "--reject" not in cmd
|
||||
assert "--comment" in cmd
|
||||
|
||||
def test_review_no_body(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="COMMENT")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--comment" not in cmd
|
||||
|
||||
|
||||
class TestCreateRelease:
|
||||
def test_create_release_full(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
release = cli.create_release(
|
||||
"owner/repo",
|
||||
tag="v1.0.0",
|
||||
title="Release 1.0.0",
|
||||
body="Notes",
|
||||
target="master",
|
||||
)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "v1.0.0" in cmd
|
||||
assert "--title" in cmd
|
||||
assert "--note" in cmd
|
||||
assert "--target" in cmd
|
||||
assert release["tag"] == "v1.0.0"
|
||||
|
||||
def test_create_release_draft(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--draft" in cmd
|
||||
|
||||
def test_create_release_prerelease(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--prerelease" in cmd
|
||||
|
||||
def test_create_release_minimal(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
release = cli.create_release("owner/repo", tag="v1.0.0")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--title" not in cmd
|
||||
assert "--note" not in cmd
|
||||
assert release["tag"] == "v1.0.0"
|
||||
|
||||
|
||||
class TestListReleases:
|
||||
def test_list_releases_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
releases = cli.list_releases("owner/repo")
|
||||
assert len(releases) == 2
|
||||
|
||||
def test_list_releases_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
releases = cli.list_releases("owner/repo")
|
||||
assert releases == []
|
||||
|
||||
|
||||
class TestListBranches:
|
||||
def test_list_branches_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
branches = cli.list_branches("owner/repo")
|
||||
assert len(branches) == 2
|
||||
|
||||
def test_list_branches_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
branches = cli.list_branches("owner/repo")
|
||||
assert branches == []
|
||||
|
||||
|
||||
class TestWhoami:
|
||||
def test_whoami(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert cli.whoami() == "emil"
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
|
||||
import devx.tools.install_checkmake as install_checkmake
|
||||
|
||||
|
||||
class TestArch:
|
||||
def test_amd64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
assert install_checkmake._arch() == "amd64"
|
||||
|
||||
def test_arm64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="aarch64"):
|
||||
assert install_checkmake._arch() == "arm64"
|
||||
|
||||
def test_unsupported(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="riscv64"):
|
||||
with pytest.raises(ClickException):
|
||||
install_checkmake._arch()
|
||||
|
||||
|
||||
class TestInstallWithGo:
|
||||
def test_no_go(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_checkmake._install_with_go() is False
|
||||
|
||||
def test_with_go(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/go"):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
assert install_checkmake._install_with_go() is True
|
||||
mock_run.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/go",
|
||||
"install",
|
||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
class TestDownloadBinary:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "checkmake"
|
||||
|
||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return path, None
|
||||
|
||||
with patch.object(install_checkmake, "TARGET_PATH", target):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_checkmake._download_binary()
|
||||
mock_retrieve.assert_called_once()
|
||||
assert target.exists()
|
||||
assert target.stat().st_mode & 0o111
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
||||
install_checkmake.main()
|
||||
|
||||
def test_install_with_go(self) -> None:
|
||||
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
install_checkmake.main()
|
||||
mock_run.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/go",
|
||||
"install",
|
||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def test_download_when_no_go(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "checkmake"
|
||||
|
||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return path, None
|
||||
|
||||
with patch.object(install_checkmake, "TARGET_PATH", target):
|
||||
with patch("shutil.which", side_effect=[None, None]):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_checkmake.main()
|
||||
mock_retrieve.assert_called_once()
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.tools.install_tools as install_tools
|
||||
|
||||
|
||||
class TestArch:
|
||||
def test_amd64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
assert install_tools._arch() == "amd64"
|
||||
|
||||
def test_arm64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="aarch64"):
|
||||
assert install_tools._arch() == "arm64"
|
||||
|
||||
def test_unsupported(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="riscv64"):
|
||||
with pytest.raises(ClickException):
|
||||
install_tools._arch()
|
||||
|
||||
|
||||
class TestIsInstalled:
|
||||
def test_on_path(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/actionlint"):
|
||||
assert install_tools._is_installed("actionlint") is True
|
||||
|
||||
def test_in_target_dir(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
(tmp_path / "actionlint").touch()
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_tools._is_installed("actionlint") is True
|
||||
|
||||
def test_not_installed(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_tools._is_installed("actionlint") is False
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "file.bin"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"data")
|
||||
return str(path), None
|
||||
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_tools._download("https://example.com/file", dest)
|
||||
mock_retrieve.assert_called_once()
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
|
||||
class TestDownloadBinary:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "act_runner"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
|
||||
assert result == dest
|
||||
assert dest.exists()
|
||||
assert dest.stat().st_mode & 0o111
|
||||
|
||||
|
||||
class TestDownloadAndExtractTarball:
|
||||
def test_extract(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
# Create a fake tarball with a binary
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake binary"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="actionlint")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
target_dir = tmp_path / "bin"
|
||||
target_dir.mkdir()
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
result = install_tools._download_and_extract_tarball(
|
||||
"https://example.com/actionlint.tar.gz", "actionlint"
|
||||
)
|
||||
|
||||
assert result == target_dir / "actionlint"
|
||||
assert result.exists()
|
||||
assert result.read_bytes() == binary_content
|
||||
|
||||
def test_binary_not_found(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="other_file")
|
||||
info.size = 0
|
||||
tar.addfile(info, io.BytesIO(b""))
|
||||
|
||||
target_dir = tmp_path / "bin"
|
||||
target_dir.mkdir()
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
with pytest.raises(ClickException, match="not found in archive"):
|
||||
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
|
||||
|
||||
|
||||
class TestInstallActionlint:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_actionlint() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake actionlint"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="actionlint")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_actionlint() is True
|
||||
assert (tmp_path / "actionlint").exists()
|
||||
|
||||
|
||||
class TestInstallGitCliff:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_git_cliff() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake git-cliff"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="git-cliff")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_git_cliff() is True
|
||||
assert (tmp_path / "git-cliff").exists()
|
||||
|
||||
|
||||
class TestInstallActRunner:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_act_runner() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_act_runner() is True
|
||||
assert (tmp_path / "act_runner").exists()
|
||||
|
||||
|
||||
class TestInstallTea:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_tea() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_tea() is True
|
||||
assert (tmp_path / "tea").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
|
||||
install_tools.list_tools()
|
||||
|
||||
|
||||
class TestInstallTool:
|
||||
def test_actionlint(self) -> None:
|
||||
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
|
||||
assert install_tools._install_tool("actionlint") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_git_cliff(self) -> None:
|
||||
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
|
||||
assert install_tools._install_tool("git-cliff") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_act_runner(self) -> None:
|
||||
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
|
||||
assert install_tools._install_tool("act_runner") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_tea(self) -> None:
|
||||
with patch.object(install_tools, "install_tea", return_value=True) as mock:
|
||||
assert install_tools._install_tool("tea") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_list_status(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
result = runner.invoke(install_tools.main, ["--list"])
|
||||
assert result.exit_code == 0
|
||||
assert "actionlint" in result.output
|
||||
|
||||
def test_install_all(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 4
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
||||
assert result.exit_code == 0
|
||||
mock_install.assert_called_once_with("actionlint")
|
||||
|
||||
def test_install_multiple_specific_tools(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 2
|
||||
|
||||
def test_install_failure(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
|
||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When TARGET_DIR is not in PATH, a reminder is printed."""
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Add" in result.output
|
||||
assert "PATH" in result.output
|
||||
|
||||
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When TARGET_DIR is in PATH, no reminder is printed."""
|
||||
target_dir = str(install_tools.TARGET_DIR)
|
||||
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Add" not in result.output
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.molecule.molecule_all as molecule_all
|
||||
|
||||
|
||||
class TestRunMolecule:
|
||||
def test_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
||||
assert rc == 1
|
||||
|
||||
def test_non_default_scenario_adds_flag(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-s" in cmd
|
||||
assert "lifecycle" in cmd
|
||||
|
||||
|
||||
class TestRunPlatform:
|
||||
def test_all_scenarios_pass(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
with patch("devx.molecule.molecule_all._run_molecule", return_value=0) as mock_run:
|
||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
||||
assert rc == 0
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_stops_on_failure(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
with patch("devx.molecule.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
|
||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
||||
assert rc == 1
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_sets_env_vars(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
def _capture_env(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
import subprocess
|
||||
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("subprocess.run", side_effect=_capture_env):
|
||||
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
|
||||
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
|
||||
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
||||
|
||||
def test_empty_command_removes_env(self) -> None:
|
||||
platform = {"name": "custom", "image": "custom:latest", "command": ""}
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
def _capture_env(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
import subprocess
|
||||
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("subprocess.run", side_effect=_capture_env):
|
||||
molecule_all._run_platform(
|
||||
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
|
||||
)
|
||||
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "molecule not found" in result.output
|
||||
|
||||
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
assert "Role directory not found" in result.output
|
||||
|
||||
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("devx.molecule.molecule_all._run_platform", return_value=0):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule scenarios passed" in result.output
|
||||
|
||||
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("devx.molecule.molecule_all._run_platform", return_value=1):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.molecule.molecule_ci_guard import (
|
||||
any_other_runner_failed,
|
||||
build_env_for_pair,
|
||||
build_molecule_cmd,
|
||||
cli,
|
||||
get_running_jobs,
|
||||
poll_for_other_failures,
|
||||
)
|
||||
|
||||
|
||||
class TestGetRunningJobs:
|
||||
def test_returns_jobs(self) -> None:
|
||||
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"jobs": [
|
||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||
assert len(jobs) == 2
|
||||
mock_get.assert_called_once()
|
||||
|
||||
def test_raises_on_request_error(self) -> None:
|
||||
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
|
||||
mock_get.side_effect = requests.RequestException("boom")
|
||||
with pytest.raises(requests.RequestException):
|
||||
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||
|
||||
|
||||
class TestAnyOtherRunnerFailed:
|
||||
def test_detects_other_failure(self) -> None:
|
||||
jobs = [
|
||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
{"name": "molecule-tests (2)", "conclusion": "running"},
|
||||
]
|
||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
|
||||
|
||||
def test_ignores_current_runner(self) -> None:
|
||||
jobs = [
|
||||
{"name": "molecule-tests (0)", "conclusion": "failure"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "success"},
|
||||
]
|
||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
||||
|
||||
def test_ignores_non_molecule_jobs(self) -> None:
|
||||
jobs = [
|
||||
{"name": "quality", "conclusion": "failure"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "success"},
|
||||
]
|
||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
||||
|
||||
|
||||
class TestBuildMoleculeCmd:
|
||||
def test_default_scenario(self) -> None:
|
||||
assert build_molecule_cmd("default") == ["molecule", "test"]
|
||||
|
||||
def test_named_scenario(self) -> None:
|
||||
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
|
||||
|
||||
|
||||
class TestBuildEnvForPair:
|
||||
def test_with_command(self) -> None:
|
||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
|
||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
|
||||
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
||||
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
|
||||
|
||||
def test_without_command(self) -> None:
|
||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
|
||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
||||
|
||||
def test_without_command_removes_existing(self) -> None:
|
||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
|
||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
||||
|
||||
|
||||
class TestPollForOtherFailures:
|
||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
||||
stop_event = MagicMock()
|
||||
failed_event = MagicMock()
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
if stop_event.wait.call_count < 1:
|
||||
return [
|
||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
return []
|
||||
|
||||
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||
mock_get_jobs.side_effect = side_effect
|
||||
stop_event.is_set.side_effect = [False, False]
|
||||
stop_event.wait.return_value = True
|
||||
|
||||
poll_for_other_failures(
|
||||
"https://gitea.example",
|
||||
"owner",
|
||||
"repo",
|
||||
"token",
|
||||
123,
|
||||
"molecule-tests",
|
||||
0,
|
||||
stop_event,
|
||||
failed_event,
|
||||
)
|
||||
|
||||
failed_event.set.assert_called_once()
|
||||
|
||||
def test_poll_warns_on_api_error(self) -> None:
|
||||
stop_event = MagicMock()
|
||||
failed_event = MagicMock()
|
||||
|
||||
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||
mock_get_jobs.side_effect = requests.RequestException("boom")
|
||||
stop_event.is_set.side_effect = [False, True]
|
||||
stop_event.wait.return_value = True
|
||||
|
||||
poll_for_other_failures(
|
||||
"https://gitea.example",
|
||||
"owner",
|
||||
"repo",
|
||||
"token",
|
||||
123,
|
||||
"molecule-tests",
|
||||
0,
|
||||
stop_event,
|
||||
failed_event,
|
||||
)
|
||||
|
||||
failed_event.set.assert_not_called()
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_all_pass(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule tests passed" in result.output
|
||||
|
||||
def test_failure_exits_nonzero(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 1
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_exits_before_starting_when_already_failed(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
mock_get_jobs.return_value = [
|
||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 1
|
||||
assert "Another molecule runner failed" in result.output
|
||||
|
||||
def test_keyboard_interrupt_kills_process(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep", side_effect=KeyboardInterrupt),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 1
|
||||
mock_killpg.assert_called()
|
||||
|
||||
def test_exits_when_other_runner_fails(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
real_sleep = time.sleep
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 1
|
||||
mock_killpg.assert_called()
|
||||
|
||||
def test_default_owner_repo_fallback(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
real_sleep = time.sleep
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "invalid",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
||||
):
|
||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
real_sleep = time.sleep
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
mock_killpg.side_effect = ProcessLookupError("no such process")
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
real_sleep = time.sleep
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.molecule.molecule_ci_guard as mg
|
||||
|
||||
with open(mg.__file__) as f:
|
||||
source = f.read()
|
||||
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
|
||||
namespace = dict(mg.__dict__)
|
||||
exec(compile(source, mg.__file__, "exec"), namespace)
|
||||
assert callable(namespace["cli"])
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Unit tests for devx/molecule/discover_runners.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.discover_runners.requests.get")
|
||||
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
|
||||
"""Network errors on all levels return 0."""
|
||||
mock_get.side_effect = __import__("requests").RequestException("network error")
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestGetRunnerCount:
|
||||
@patch("devx.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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.molecule.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
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Unit tests for scripts/ci/notify_failure.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.notify_failure import main
|
||||
from devx.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--repo",
|
||||
"owner/repo",
|
||||
"--run-id",
|
||||
"123",
|
||||
"--workflow",
|
||||
"release",
|
||||
"--commit",
|
||||
"abc123def456",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #42" in result.output
|
||||
mock_tea.create_issue.assert_called_once()
|
||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #43" in result.output
|
||||
mock_tea.add_label.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""When tea fails, the workflow fails — no fallback."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea.create_issue.side_effect = TeaCLIError("network error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "tea" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""If listing labels fails via tea, issue is still created without labels."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""If adding label fails via tea, issue is still reported as created."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
|
||||
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Unit tests for scripts/ci/platforms.py."""
|
||||
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
|
||||
|
||||
class TestPlatforms:
|
||||
def test_platforms_not_empty(self) -> None:
|
||||
assert len(PLATFORMS) >= 4
|
||||
|
||||
def test_each_platform_has_required_keys(self) -> None:
|
||||
for p in PLATFORMS:
|
||||
assert "name" in p
|
||||
assert "image" in p
|
||||
assert "command" in p
|
||||
|
||||
def test_platform_names_unique(self) -> None:
|
||||
names = [p["name"] for p in PLATFORMS]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_known_platforms_present(self) -> None:
|
||||
names = {p["name"] for p in PLATFORMS}
|
||||
assert "ubuntu-2204" in names
|
||||
assert "ubuntu-2404" in names
|
||||
assert "debian-12" in names
|
||||
assert "archlinux" in names
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Unit tests for scripts/ci/post_merge.py."""
|
||||
|
||||
import http
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.post_merge import (
|
||||
_get_git_commit_message,
|
||||
_get_git_commit_sha,
|
||||
build_comment,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
resolve_task_id,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_first_line(self) -> None:
|
||||
assert extract_task_id("DEVX-19: fix: resolve bug\n\nBody") == "DEVX-19"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
assert extract_task_id("fix: resolve bug") == ""
|
||||
|
||||
|
||||
class TestExtractConventionalMsg:
|
||||
def test_strips_colon_prefix(self) -> None:
|
||||
"""Legacy format: DEVX-N: <message>"""
|
||||
assert extract_conventional_msg("DEVX-19: fix: resolve bug") == "fix: resolve bug"
|
||||
|
||||
def test_strips_space_prefix(self) -> None:
|
||||
"""Current format: DEVX-N <message>"""
|
||||
assert extract_conventional_msg("DEVX-19 fix: resolve bug") == "fix: resolve bug"
|
||||
|
||||
def test_returns_unchanged_without_prefix(self) -> None:
|
||||
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
|
||||
|
||||
|
||||
class TestBuildComment:
|
||||
def test_html_format(self) -> None:
|
||||
html = build_comment("DEVX-19", "fix: bug", "abc123")
|
||||
assert "<strong>DEVX-19</strong>" in html
|
||||
assert "fix: bug" in html
|
||||
assert "<code>abc123</code>" in html
|
||||
|
||||
|
||||
class TestResolveTaskId:
|
||||
def test_found(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 42, "identifier": "DEVX-19"},
|
||||
]
|
||||
assert resolve_task_id(mock_client, "DEVX-19") == 42
|
||||
mock_client.list_project_tasks.assert_called_once()
|
||||
|
||||
def test_not_found_raises(self) -> None:
|
||||
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
resolve_task_id(mock_client, "DEVX-99")
|
||||
|
||||
def test_found_on_second_page(self) -> None:
|
||||
"""Task is on page 2 when project has more than 50 tasks."""
|
||||
mock_client = MagicMock()
|
||||
page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(50)]
|
||||
page2 = [{"id": 100, "identifier": "DEVX-99"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
assert resolve_task_id(mock_client, "DEVX-99") == 100
|
||||
assert mock_client.list_project_tasks.call_count == 2
|
||||
|
||||
def test_stops_when_page_is_partial(self) -> None:
|
||||
"""Stops paginating when a page has fewer than DEFAULT_PER_PAGE results."""
|
||||
mock_client = MagicMock()
|
||||
page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(10)]
|
||||
mock_client.list_project_tasks.return_value = page1
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
resolve_task_id(mock_client, "DEVX-99")
|
||||
assert mock_client.list_project_tasks.call_count == 1
|
||||
|
||||
def test_http_error_propagates(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
with pytest.raises(APIError):
|
||||
resolve_task_id(mock_client, "DEVX-19")
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_full_flow(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
mock_client.post_comment.assert_called_once()
|
||||
mock_client.update_task.assert_called_once_with(267, done=True)
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: resolve bug"])
|
||||
assert result.exit_code == 0
|
||||
mock_client.post_comment.assert_called_once()
|
||||
args, _ = mock_client.post_comment.call_args
|
||||
assert "unknown" in args[1]
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 1
|
||||
assert "VIKUNJA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_task_id_non_release_warns(self) -> None:
|
||||
"""Non-release commits without DEVX-N prefix should warn, not fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["fix: resolve bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "No task ID" in result.output
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_release_commit_without_task_id_skips(self) -> None:
|
||||
"""Release commits without DEVX-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["release: v0.3.2"])
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_revert_commit_skips(self) -> None:
|
||||
"""Revert commits without DEVX-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
|
||||
assert result.exit_code == 0
|
||||
assert "Infrastructure commit" in result.output
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_merge_commit_skips(self) -> None:
|
||||
"""Merge commits without DEVX-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["Merge pull request #42"])
|
||||
assert result.exit_code == 0
|
||||
assert "Infrastructure commit" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_resolve_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code != 0
|
||||
assert "Could not find" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client.post_comment.return_value = None
|
||||
mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
|
||||
|
||||
class TestGetGitCommitMessage:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_message() == "DEVX-20: fix: bug"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(click.ClickException, match="git log failed"):
|
||||
_get_git_commit_message()
|
||||
|
||||
|
||||
class TestGetGitCommitSha:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_sha() == "abc123"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(click.ClickException, match="git rev-parse failed"):
|
||||
_get_git_commit_sha()
|
||||
|
||||
|
||||
class TestFromGit:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
|
||||
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-git"])
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
|
||||
def test_from_git_with_explicit_sha(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_msg_and_no_from_git(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "commit_msg" in result.output
|
||||
|
||||
|
||||
class TestGitSha:
|
||||
"""Tests for the --git-sha option (race condition fix)."""
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
"""--git-sha reads commit message from a specific SHA, not HEAD."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git-sha", "abc123"])
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
mock_client.post_comment.assert_called_once()
|
||||
# Verify the SHA was passed to the comment
|
||||
args, _ = mock_client.post_comment.call_args
|
||||
assert "abc123" in args[1]
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_git_sha_failure_raises(self) -> None:
|
||||
"""--git-sha with invalid SHA should raise."""
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git-sha", "badsha"])
|
||||
assert result.exit_code != 0
|
||||
assert "git log failed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
|
||||
assert result.exit_code == 0
|
||||
args, _ = mock_client.post_comment.call_args
|
||||
assert "explicit_sha" in args[1]
|
||||
@@ -0,0 +1,681 @@
|
||||
"""Unit tests for scripts/ci/pr_review.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.pr_review import (
|
||||
ReviewResult,
|
||||
build_review_body,
|
||||
check_architecture_compliance,
|
||||
check_best_practices,
|
||||
check_commit_conventions,
|
||||
check_documentation,
|
||||
check_function_length,
|
||||
check_i18n,
|
||||
check_resource_management,
|
||||
check_security,
|
||||
check_test_coverage,
|
||||
is_python_file,
|
||||
is_workflow_only,
|
||||
main,
|
||||
post_review,
|
||||
run_review,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestIsPythonFile:
|
||||
def test_python_file_in_src(self) -> None:
|
||||
assert is_python_file("src/devx/cli.py") is True
|
||||
|
||||
def test_python_file_in_scripts(self) -> None:
|
||||
assert is_python_file("scripts/ci/release.py") is True
|
||||
|
||||
def test_test_file_excluded(self) -> None:
|
||||
assert is_python_file("tests/unit/test_cli.py") is False
|
||||
|
||||
def test_non_python_file(self) -> None:
|
||||
assert is_python_file("README.md") is False
|
||||
|
||||
def test_yaml_file(self) -> None:
|
||||
assert is_python_file(".gitea/workflows/ci.yml") is False
|
||||
|
||||
|
||||
class TestIsWorkflowOnly:
|
||||
def test_yaml_is_workflow(self) -> None:
|
||||
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
||||
|
||||
def test_md_is_workflow(self) -> None:
|
||||
assert is_workflow_only("README.md") is True
|
||||
|
||||
def test_python_is_not_workflow(self) -> None:
|
||||
assert is_workflow_only("src/devx/cli.py") is False
|
||||
|
||||
def test_ansible_is_workflow(self) -> None:
|
||||
assert is_workflow_only("ansible/tasks/main.yml") is True
|
||||
|
||||
|
||||
class TestReviewResult:
|
||||
def test_empty_result_has_no_issues(self) -> None:
|
||||
result = ReviewResult()
|
||||
assert result.has_issues is False
|
||||
|
||||
def test_add_issue_makes_has_issues_true(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_issue("src/foo.py", 10, "bad code")
|
||||
assert result.has_issues is True
|
||||
assert len(result.issues) == 1
|
||||
assert result.issues[0]["path"] == "src/foo.py"
|
||||
assert result.issues[0]["new_position"] == 10
|
||||
|
||||
def test_add_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_summary("all good")
|
||||
assert "all good" in result.summary
|
||||
|
||||
|
||||
class TestCheckArchitectureCompliance:
|
||||
def test_subprocess_in_cli_triggers_issue(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
|
||||
}
|
||||
]
|
||||
check_architecture_compliance(files, result)
|
||||
assert result.has_issues
|
||||
assert "subprocess" in result.issues[0]["body"].lower()
|
||||
|
||||
def test_subprocess_in_other_file_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/executor.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
|
||||
}
|
||||
]
|
||||
check_architecture_compliance(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_no_changes_adds_ok_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": ""}]
|
||||
check_architecture_compliance(files, result)
|
||||
assert any("Architecture compliance: OK" in s for s in result.summary)
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
|
||||
check_architecture_compliance(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": ""}]
|
||||
check_architecture_compliance(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_os_system_in_cli_triggers_issue(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
|
||||
}
|
||||
]
|
||||
check_architecture_compliance(files, result)
|
||||
assert result.has_issues
|
||||
assert "os.system" in result.issues[0]["body"]
|
||||
|
||||
|
||||
class TestCheckBestPractices:
|
||||
def test_print_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "print()" in result.issues[0]["body"]
|
||||
|
||||
def test_bare_except_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/runner_manager.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "bare except" in result.issues[0]["body"]
|
||||
|
||||
def test_todo_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "TODO" in result.issues[0]["body"]
|
||||
|
||||
def test_clean_code_no_issues(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": ""}]
|
||||
check_best_practices(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
|
||||
check_best_practices(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
|
||||
class TestCheckSecurity:
|
||||
def test_hardcoded_secret_triggers_error(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/config.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
|
||||
}
|
||||
]
|
||||
check_security(files, result)
|
||||
assert result.has_issues
|
||||
assert "secret" in result.issues[0]["body"].lower()
|
||||
|
||||
def test_example_token_not_flagged(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": ".env.example",
|
||||
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
|
||||
}
|
||||
]
|
||||
check_security(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_shell_true_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/executor.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "shell=True" in result.issues[0]["body"]
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/config.py", "patch": ""}]
|
||||
check_security(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
|
||||
check_security(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
|
||||
class TestCheckI18n:
|
||||
def test_raw_string_in_echo_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
|
||||
check_i18n(files, result)
|
||||
assert result.has_issues
|
||||
assert any("i18n" in i["body"] for i in result.issues)
|
||||
|
||||
def test_translated_string_no_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}]
|
||||
check_i18n(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_fstring_in_echo_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(f"Hello {name}")\n'}]
|
||||
check_i18n(files, result)
|
||||
assert result.has_issues
|
||||
|
||||
def test_raw_exception_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": '@@ -1,1 +1,1 @@\n+raise click.ClickException("Something went wrong")\n',
|
||||
}
|
||||
]
|
||||
check_i18n(files, result)
|
||||
assert result.has_issues
|
||||
|
||||
def test_non_src_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "scripts/ci/test.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
|
||||
check_i18n(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_comment_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# click.echo("Hello world")\n'}]
|
||||
check_i18n(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": ""}]
|
||||
check_i18n(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_clean_code_adds_ok_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}]
|
||||
check_i18n(files, result)
|
||||
assert any("i18n: OK" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckResourceManagement:
|
||||
def test_open_without_with_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
|
||||
check_resource_management(files, result)
|
||||
assert result.has_issues
|
||||
assert any("resource" in i["body"].lower() for i in result.issues)
|
||||
|
||||
def test_open_with_with_no_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ pass\n',
|
||||
}
|
||||
]
|
||||
check_resource_management(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_popen_without_cleanup_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/executor.py",
|
||||
"patch": '@@ -1,1 +1,1 @@\n+proc = subprocess.Popen(["cmd"])\n',
|
||||
}
|
||||
]
|
||||
check_resource_management(files, result)
|
||||
assert result.has_issues
|
||||
|
||||
def test_popen_with_communicate_no_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/executor.py",
|
||||
"patch": '@@ -1,1 +1,1 @@\n+out, err = subprocess.Popen(["cmd"], stdout=PIPE).communicate()\n',
|
||||
}
|
||||
]
|
||||
check_resource_management(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_comment_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# f = open("file.txt")\n'}]
|
||||
check_resource_management(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": ""}]
|
||||
check_resource_management(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/config.md", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
|
||||
check_resource_management(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_clean_code_adds_ok_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ data = f.read()\n',
|
||||
}
|
||||
]
|
||||
check_resource_management(files, result)
|
||||
assert any("Resource management: OK" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckFunctionLength:
|
||||
def test_long_function_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
# Create a patch with a function that adds > 50 lines
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
|
||||
files = [{"filename": "src/devx/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert result.has_issues
|
||||
assert "foo" in result.issues[0]["body"]
|
||||
|
||||
def test_short_function_no_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
|
||||
files = [{"filename": "src/devx/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py", "patch": ""}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
|
||||
files = [{"filename": "README.md", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_multiple_functions_resets_count(self) -> None:
|
||||
"""Two short functions back-to-back should not trigger the length warning."""
|
||||
result = ReviewResult()
|
||||
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
|
||||
files = [{"filename": "src/devx/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_long_function_followed_by_new_hunk(self) -> None:
|
||||
"""Long function followed by @@ header triggers the warning at hunk boundary."""
|
||||
result = ReviewResult()
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = (
|
||||
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
|
||||
)
|
||||
files = [{"filename": "src/devx/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert result.has_issues
|
||||
assert "foo" in result.issues[0]["body"]
|
||||
|
||||
def test_long_function_followed_by_new_def(self) -> None:
|
||||
"""Long function followed by another def triggers the warning at def boundary."""
|
||||
result = ReviewResult()
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
|
||||
files = [{"filename": "src/devx/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert result.has_issues
|
||||
assert "foo" in result.issues[0]["body"]
|
||||
|
||||
|
||||
class TestCheckDocumentation:
|
||||
def test_src_changes_without_docs_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py"}]
|
||||
check_documentation(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_src_changes_with_docs_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
|
||||
check_documentation(files, result)
|
||||
assert any("Documentation: OK" in s for s in result.summary)
|
||||
|
||||
def test_ansible_changes_without_docs_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
|
||||
check_documentation(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_only_doc_changes_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md"}]
|
||||
check_documentation(files, result)
|
||||
assert any("Documentation: OK" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckTestCoverage:
|
||||
def test_src_changes_without_tests_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py"}]
|
||||
check_test_coverage(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_src_changes_with_tests_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/devx/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
|
||||
check_test_coverage(files, result)
|
||||
assert any("Tests: OK" in s for s in result.summary)
|
||||
|
||||
def test_only_test_changes_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "tests/unit/test_cli.py"}]
|
||||
check_test_coverage(files, result)
|
||||
assert any("Tests: OK" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestBuildReviewBody:
|
||||
def test_body_contains_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_summary("- Architecture compliance: OK")
|
||||
body = build_review_body(result)
|
||||
assert "Architecture compliance: OK" in body
|
||||
assert "Automated PR Review" in body
|
||||
|
||||
def test_body_contains_issues(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_issue("src/foo.py", 10, "bad code")
|
||||
body = build_review_body(result)
|
||||
assert "1 issue(s) found" in body
|
||||
assert "src/foo.py:10" in body
|
||||
assert "bad code" in body
|
||||
|
||||
def test_body_contains_no_issues_message(self) -> None:
|
||||
result = ReviewResult()
|
||||
body = build_review_body(result)
|
||||
assert "No issues found" in body
|
||||
|
||||
def test_body_contains_auto_merge_note(self) -> None:
|
||||
"""Review body must mention auto-merge."""
|
||||
result = ReviewResult()
|
||||
body = build_review_body(result)
|
||||
assert "Auto-merge" in body
|
||||
|
||||
|
||||
class TestRunReview:
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
|
||||
mock_client = mock_client_class.return_value
|
||||
mock_client.get_pr_files.return_value = []
|
||||
result = run_review(mock_client, "42")
|
||||
assert "No files changed" in result.summary[0]
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
|
||||
mock_client = mock_client_class.return_value
|
||||
mock_client.get_pr_files.return_value = [
|
||||
{
|
||||
"filename": "src/devx/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
|
||||
}
|
||||
]
|
||||
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
|
||||
result = run_review(mock_client, "42")
|
||||
assert result.has_issues
|
||||
|
||||
def test_run_review_handles_api_error(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_files.side_effect = APIError(404, "Not found")
|
||||
result = run_review(client, "42")
|
||||
assert any("ERROR" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckCommitConventions:
|
||||
def test_conventional_commit_found(self) -> None:
|
||||
"""Should report OK when at least one commit is conventional."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
|
||||
{"commit": {"message": "wip: testing"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("OK" in s for s in result.summary)
|
||||
|
||||
def test_no_conventional_commit(self) -> None:
|
||||
"""Should warn when no commits are conventional."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "updated stuff"}},
|
||||
{"commit": {"message": "wip: testing"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_merge_commits_excluded(self) -> None:
|
||||
"""Merge commits should be excluded from the check."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "Merge branch 'feature' into master"}},
|
||||
{"commit": {"message": "fix: resolve bug"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("OK" in s for s in result.summary)
|
||||
|
||||
def test_all_merges_and_reverts(self) -> None:
|
||||
"""Should report OK when all commits are merges/reverts."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "Merge branch 'feature' into master"}},
|
||||
{"commit": {"message": "Revert: bad commit"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("merges/reverts" in s for s in result.summary)
|
||||
|
||||
def test_no_commits(self) -> None:
|
||||
"""Should report OK when there are no commits."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = []
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("no commits" in s for s in result.summary)
|
||||
|
||||
def test_api_error(self) -> None:
|
||||
"""Should report ERROR when API call fails."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.side_effect = APIError(500, "server error")
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("ERROR" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestPostReview:
|
||||
def test_post_review_with_issues(self) -> None:
|
||||
client = MagicMock()
|
||||
result = ReviewResult()
|
||||
result.add_issue("src/foo.py", 10, "bad code")
|
||||
post_review(client, "42", result)
|
||||
client.create_review.assert_called_once()
|
||||
call_args = client.create_review.call_args
|
||||
assert call_args[1]["event"] == "REQUEST_CHANGES"
|
||||
assert call_args[1]["comments"] == result.issues
|
||||
|
||||
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
|
||||
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
|
||||
client = MagicMock()
|
||||
result = ReviewResult()
|
||||
post_review(client, "42", result)
|
||||
client.create_review.assert_called_once()
|
||||
call_args = client.create_review.call_args
|
||||
assert call_args[1]["event"] == "COMMENT"
|
||||
assert call_args[1]["comments"] == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.pr_review.run_review")
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = ReviewResult()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "[dry-run]" in result.output
|
||||
mock_client_class.return_value.create_review.assert_not_called()
|
||||
|
||||
@patch("devx.ci.pr_review.run_review")
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = ReviewResult()
|
||||
mock_client_class.return_value.create_review.return_value = {"id": 123}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "Review #123" in result.output
|
||||
mock_client_class.return_value.create_review.assert_called_once()
|
||||
|
||||
@patch("devx.ci.pr_review.run_review")
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
|
||||
mock_run.return_value = ReviewResult()
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = [
|
||||
APIError(422, "approve your own pull is not allowed"),
|
||||
{"id": 124},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "Review #124" in result.output
|
||||
assert client.create_review.call_count == 2
|
||||
|
||||
@patch("devx.ci.pr_review.run_review")
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Non-approval API errors should re-raise, not fall back."""
|
||||
mock_run.return_value = ReviewResult()
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = APIError(500, "Internal server error")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.ci.pr_review as pr
|
||||
|
||||
with patch.object(pr, "main") as mock_main:
|
||||
with patch.object(pr, "__name__", "__main__"):
|
||||
pr.main([])
|
||||
mock_main.assert_called_once_with([])
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Unit tests for devx.ci.publish."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.publish import (
|
||||
_default_gitea_registry_url,
|
||||
build_package,
|
||||
generate_release_notes,
|
||||
main,
|
||||
publish_to_gitea_registry,
|
||||
publish_to_pypi,
|
||||
)
|
||||
from devx.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestGenerateReleaseNotes:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "## v1.0.0" in result
|
||||
assert "feat: x" in result
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert result == "changelog"
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run", side_effect=FileNotFoundError)
|
||||
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
@patch("devx.ci.publish.shutil.which", return_value=None)
|
||||
def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None:
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
|
||||
class TestBuildPackage:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
build_package()
|
||||
args, _ = mock_run.call_args
|
||||
assert args[0][1] == "-m"
|
||||
assert args[0][2] == "build"
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
build_package()
|
||||
assert "build" in str(exc.value)
|
||||
|
||||
|
||||
class TestPublishToPypi:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
publish_to_pypi("pypi-tok")
|
||||
args, _ = mock_run.call_args
|
||||
assert "twine" in args[0]
|
||||
assert "pypi-tok" in args[0]
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="upload failed")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
publish_to_pypi("pypi-tok")
|
||||
assert "PyPI" in str(exc.value)
|
||||
|
||||
|
||||
class TestPublishToGiteaRegistry:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
||||
args, _ = mock_run.call_args
|
||||
assert "twine" in args[0]
|
||||
assert "--repository-url" in args[0]
|
||||
assert "https://git.example.com/api/packages/owner/pypi" in args[0]
|
||||
assert "gitea-tok" in args[0]
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
||||
assert "Gitea PyPI registry" in str(exc.value)
|
||||
|
||||
|
||||
class TestDefaultGiteaRegistryUrl:
|
||||
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
||||
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1")
|
||||
def test_derives_from_api_url(self) -> None:
|
||||
url = _default_gitea_registry_url()
|
||||
assert url == "https://git.example.com/api/packages/myorg/pypi"
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
||||
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api")
|
||||
def test_derives_from_api_url_no_v1(self) -> None:
|
||||
url = _default_gitea_registry_url()
|
||||
assert url == "https://git.example.com/api/packages/myorg/pypi"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1")
|
||||
def test_default_owner(self) -> None:
|
||||
url = _default_gitea_registry_url()
|
||||
assert "oblachno-oss" in url
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_full_flow_with_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
mock_build.assert_called_once()
|
||||
mock_publish.assert_called_once_with("pypi-tok")
|
||||
mock_tea.create_release.assert_called_once_with(
|
||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_without_pypi_uses_gitea_registry(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_gitea_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
mock_build.assert_called_once()
|
||||
mock_gitea_publish.assert_called_once()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_with_registry_url_flag(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_gitea_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
"""--registry-url flag publishes to the specified Gitea registry."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["v1.0.0", "owner/repo", "--registry-url", "https://custom.registry.com/pypi"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_gitea_publish.assert_called_once_with("https://custom.registry.com/pypi", "gitea-tok")
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"REPO_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"},
|
||||
clear=True,
|
||||
)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_registry_url_from_env(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_gitea_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("devx.ci.publish._default_gitea_registry_url", return_value="")
|
||||
def test_no_pypi_no_registry_skips_publish(
|
||||
self,
|
||||
mock_default_url: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
|
||||
assert result.exit_code == 0
|
||||
assert "PYPI_TOKEN not set" in result.output
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_repo_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_build_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_build.side_effect = click.ClickException("build failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "build" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_publish_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_publish.side_effect = click.ClickException("publish failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "publish" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "Release creation failed" in result.output
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.ci.push_badges as push_badges
|
||||
|
||||
|
||||
class TestFetchLatestMaster:
|
||||
def test_fetch_and_reset(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.fetch_latest_master("master")
|
||||
# Should call git fetch and git reset --hard
|
||||
calls = [str(c.args[0]) for c in mock_run.call_args_list]
|
||||
assert any("fetch" in c for c in calls)
|
||||
assert any("reset" in c for c in calls)
|
||||
|
||||
def test_custom_branch(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.fetch_latest_master("develop")
|
||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
||||
# fetch call should include the branch name
|
||||
fetch_call = [c for c in calls if "fetch" in c][0]
|
||||
assert "develop" in fetch_call
|
||||
# reset call should include origin/develop
|
||||
reset_call = [c for c in calls if "reset" in c][0]
|
||||
assert "origin/develop" in reset_call
|
||||
|
||||
def test_fetch_failure_raises(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
push_badges.fetch_latest_master()
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / ".badges"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "badge1.svg").touch()
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.generate_badges(str(output_dir))
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_no_badges_generated(self, tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / ".badges"
|
||||
output_dir.mkdir()
|
||||
|
||||
with patch("subprocess.run"):
|
||||
with pytest.raises(ClickException, match="No badge SVG files generated"):
|
||||
push_badges.generate_badges(str(output_dir))
|
||||
|
||||
|
||||
class TestPushToBadgesBranch:
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
svg = badges_dir / "badge1.svg"
|
||||
svg.write_text("<svg></svg>")
|
||||
|
||||
sha_result = MagicMock()
|
||||
sha_result.stdout = "abc123\n"
|
||||
default_result = MagicMock()
|
||||
with patch(
|
||||
"subprocess.run",
|
||||
side_effect=[default_result] * 7 + [sha_result],
|
||||
) as mock_run:
|
||||
sha = push_badges.push_to_badges_branch(str(badges_dir))
|
||||
|
||||
# Should have called git config x2, checkout, rm, add, commit, push, rev-parse
|
||||
assert mock_run.call_count >= 8
|
||||
# Verify the SVG was copied to cwd
|
||||
assert (tmp_path / "badge1.svg").exists()
|
||||
assert sha == "abc123"
|
||||
|
||||
|
||||
class TestUpdateBadgeUrls:
|
||||
def test_replaces_branch_url(self) -> None:
|
||||
content = "[]"
|
||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
||||
assert "raw/commit/abc123def456/tests.svg" in result
|
||||
assert "raw/branch/badges" not in result
|
||||
|
||||
def test_replaces_commit_url(self) -> None:
|
||||
"""Old commit SHA URLs should be replaced with the new one."""
|
||||
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
|
||||
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
|
||||
content = f"[]"
|
||||
result = push_badges.update_badge_urls(content, new_sha)
|
||||
assert f"raw/commit/{new_sha}/tests.svg" in result
|
||||
assert old_sha not in result
|
||||
|
||||
def test_no_badge_urls(self) -> None:
|
||||
content = "# No badges here\nJust text."
|
||||
result = push_badges.update_badge_urls(content, "abc123")
|
||||
assert result == content
|
||||
|
||||
def test_multiple_badges(self) -> None:
|
||||
content = (
|
||||
"[]\n"
|
||||
"[]\n"
|
||||
"[]"
|
||||
)
|
||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
||||
assert result.count("raw/commit/abc123def456/") == 3
|
||||
assert "raw/branch/badges" not in result
|
||||
|
||||
def test_preserves_non_badge_urls(self) -> None:
|
||||
content = "[]"
|
||||
result = push_badges.update_badge_urls(content, "abc123")
|
||||
assert result == content
|
||||
|
||||
|
||||
class TestUpdateReadmeWithBadgeSha:
|
||||
def test_updates_readme(self, tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("[]")
|
||||
with patch("subprocess.run"):
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
content = readme.read_text()
|
||||
assert "raw/commit/abc123def456/tests.svg" in content
|
||||
|
||||
def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("# No badges here")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
# Should checkout master but not commit/push
|
||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
||||
assert not any("commit" in c for c in calls)
|
||||
assert not any("push" in c for c in calls)
|
||||
|
||||
def test_missing_readme_skips(self, tmp_path: Path) -> None:
|
||||
with patch("subprocess.run"):
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
# Should not raise
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run") as mock_run:
|
||||
result = runner.invoke(
|
||||
push_badges.main,
|
||||
["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
# Verify fetch was called with the custom branch
|
||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
||||
fetch_call = [c for c in calls if "fetch" in c][0]
|
||||
assert "develop" in fetch_call
|
||||
|
||||
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
import subprocess
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
||||
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Without --no-readme-update, update_readme_with_badge_sha is called."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run"),
|
||||
patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
||||
):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
assert result.exit_code == 0
|
||||
mock_update.assert_called_once()
|
||||
|
||||
def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With --no-readme-update, update_readme_with_badge_sha is not called."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run"),
|
||||
patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
||||
):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code == 0
|
||||
mock_update.assert_not_called()
|
||||
@@ -0,0 +1,640 @@
|
||||
"""Unit tests for scripts/ci/release.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.release import (
|
||||
commit_release_changes,
|
||||
create_and_push_tag,
|
||||
get_bumped_version,
|
||||
get_changelog,
|
||||
get_latest_tag,
|
||||
has_unreleased_changes,
|
||||
main,
|
||||
run_cmd,
|
||||
run_tests,
|
||||
tag_exists,
|
||||
update_changelog,
|
||||
update_init_version,
|
||||
)
|
||||
|
||||
|
||||
class TestRunCmd:
|
||||
@patch("devx.ci.release.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||
result = run_cmd(["echo", "hi"])
|
||||
assert result.returncode == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("devx.ci.release.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_cmd(["false"])
|
||||
|
||||
@patch("devx.ci.release.subprocess.run")
|
||||
def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
result = run_cmd(["false"], check=False)
|
||||
assert result.returncode == 1
|
||||
|
||||
|
||||
class TestGetLatestTag:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert get_latest_tag() == "v0.1.0"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert get_latest_tag() == ""
|
||||
|
||||
|
||||
class TestTagExists:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_exists(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert tag_exists("v0.1.0") is True
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_not_exists(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
assert tag_exists("v0.2.0") is False
|
||||
|
||||
|
||||
class TestGetBumpedVersion:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_version(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n")
|
||||
assert get_bumped_version() == "0.2.0"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
|
||||
assert get_bumped_version() == "0.2.0"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_empty_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
with pytest.raises(click.ClickException):
|
||||
get_bumped_version()
|
||||
|
||||
|
||||
class TestGetChangelog:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n")
|
||||
assert get_changelog("0.2.0") == "## 0.2.0\n- fix"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n")
|
||||
assert get_changelog("0.2.0") == "text"
|
||||
|
||||
|
||||
class TestHasUnreleasedChanges:
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_no_tags_has_changes(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = ""
|
||||
assert has_unreleased_changes() is True
|
||||
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_no_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123 fix: bug\n")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is True
|
||||
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_git_log_fails_returns_false(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
|
||||
class TestUpdateInitVersion:
|
||||
def test_updates_version(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.1.0"\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
update_init_version("0.2.0")
|
||||
assert '__version__ = "0.2.0"' in init_file.read_text()
|
||||
|
||||
def test_same_version_ok(self, tmp_path, monkeypatch) -> None:
|
||||
"""Updating to the same version should not raise."""
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.1.0"\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
update_init_version("0.1.0")
|
||||
assert '__version__ = "0.1.0"' in init_file.read_text()
|
||||
|
||||
def test_no_version_raises(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('"""module"""\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
with pytest.raises(click.ClickException):
|
||||
update_init_version("0.2.0")
|
||||
|
||||
|
||||
class TestUpdateChangelog:
|
||||
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
||||
content = changelog_file.read_text()
|
||||
assert "## [0.2.0]" in content
|
||||
assert "new thing" in content
|
||||
|
||||
def test_prepends_to_existing(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
||||
content = changelog_file.read_text()
|
||||
assert "# Changelog" in content
|
||||
# New version should be before old version
|
||||
assert content.index("0.2.0") < content.index("0.1.0")
|
||||
assert "new thing" in content
|
||||
assert "old thing" in content
|
||||
|
||||
def test_appends_when_no_version_sections(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
changelog_file.write_text("# Changelog\n\nSome intro text.\n")
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
||||
content = changelog_file.read_text()
|
||||
assert "Some intro text" in content
|
||||
assert "## [0.2.0]" in content
|
||||
|
||||
def test_strips_git_cliff_header(self, tmp_path, monkeypatch) -> None:
|
||||
"""git-cliff output includes a header — should be stripped before inserting."""
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
# Simulate git-cliff output with header
|
||||
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
|
||||
update_changelog(cliff_output)
|
||||
content = changelog_file.read_text()
|
||||
# Header should appear only once (from the existing file)
|
||||
assert content.count("# Changelog") == 1
|
||||
assert "## [0.2.0]" in content
|
||||
assert "new thing" in content
|
||||
|
||||
def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None:
|
||||
"""When creating a new file, strip the git-cliff header."""
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
|
||||
update_changelog(cliff_output)
|
||||
content = changelog_file.read_text()
|
||||
assert "# Changelog" not in content
|
||||
assert "## [0.2.0]" in content
|
||||
|
||||
|
||||
class TestCommitReleaseChanges:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_commits_when_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
# git diff --cached --quiet returns 1 (changes exist)
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
result = commit_release_changes("0.2.0")
|
||||
assert result is True
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
# git diff --cached --quiet returns 0 (no changes)
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
result = commit_release_changes("0.1.0")
|
||||
assert result is False
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
|
||||
|
||||
|
||||
class TestCreateAndPushTag:
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
assert ["git", "push", "origin", "v0.2.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
result = create_and_push_tag("0.2.0", "changelog", dry_run=True)
|
||||
assert result is True
|
||||
for call in mock_run_cmd.call_args_list:
|
||||
assert call.args[0][0:2] != ["git", "push"]
|
||||
assert call.args[0][0:2] != ["git", "tag"]
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
||||
assert result is False
|
||||
# Should not create tag, but should ensure it's pushed
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
|
||||
assert result is False
|
||||
# No git commands at all in dry-run when tag exists
|
||||
mock_run_cmd.assert_not_called()
|
||||
|
||||
|
||||
class TestRunTests:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
run_tests() # should not raise
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error")
|
||||
with pytest.raises(click.ClickException, match="Lint failed"):
|
||||
run_tests()
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
|
||||
MagicMock(returncode=1, stdout="", stderr="test failure"), # tests fail
|
||||
]
|
||||
with pytest.raises(click.ClickException, match="Tests failed"):
|
||||
run_tests()
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tests_fail_stdout_fallback(self, mock_run_cmd: MagicMock) -> None:
|
||||
"""When stderr is empty, test output from stdout is used in the error message."""
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
|
||||
MagicMock(returncode=1, stdout="test failure on stdout", stderr=""), # tests fail, stderr empty
|
||||
]
|
||||
with pytest.raises(click.ClickException, match="test failure on stdout"):
|
||||
run_tests()
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
||||
"""Dry-run mode should not fail on non-master branches."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "Dry-run mode" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
|
||||
self, mock_run_cmd: MagicMock, mock_uf: MagicMock
|
||||
) -> None:
|
||||
"""If HEAD is a release commit and the tag exists, skip."""
|
||||
# git rev-parse, git log -1, git tag -l (tag exists)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "already a release commit" in result.output
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_changelog", return_value="## changelog")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_lock_recovers_when_tag_missing(
|
||||
self, mock_run_cmd: MagicMock, mock_create_tag: MagicMock, mock_changelog: MagicMock
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but the tag is missing, create the tag."""
|
||||
# git rev-parse, git log -1, git tag -l (tag NOT found)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # tag -l finds nothing
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "tag v0.5.0 is missing" in result.output
|
||||
assert "Recovering" in result.output
|
||||
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=False)
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_no_unreleased_changes(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No unreleased changes" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_dry_run_empty_changelog(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "empty changelog" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_dry_run(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "[dry-run]" in result.output
|
||||
mock_update_init.assert_not_called()
|
||||
mock_update_changelog.assert_not_called()
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.3.0")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_skips_release_when_workflow_only(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_user_facing: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
) -> None:
|
||||
"""Release is skipped when only workflow/infra files changed."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No user-facing changes" in result.output
|
||||
assert "Skipping release" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_full_flow(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Bumping version" in result.output
|
||||
mock_update_init.assert_called_once_with("0.2.0")
|
||||
mock_update_changelog.assert_called_once_with("changelog")
|
||||
mock_run_tests.assert_called_once()
|
||||
mock_commit.assert_called_once_with("0.2.0")
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=False)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=False)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_full_flow_tag_exists(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""When tag already exists, still update files but report existing tag."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "already existed" in result.output
|
||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_full_flow_skip_tests(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""--skip-tests bypasses test verification."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--skip-tests"])
|
||||
assert result.exit_code == 0
|
||||
assert "WARNING: --skip-tests" in result.output
|
||||
# run_tests should NOT be called — verify no "make lint-ruff" or "make pytest-cov" calls
|
||||
make_calls = [c.args[0] for c in mock_run_cmd.call_args_list if c.args[0][:1] == ["make"]]
|
||||
assert make_calls == []
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tests_fail_aborts_before_tag(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If tests fail, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
# make lint-ruff (success), make pytest-cov (failure)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="DEVX-50 fix: something\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="test failure"),
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Tests failed" in result.output
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_lint_fail_aborts_before_tag(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If lint fails, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
# make lint-ruff (failure)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="DEVX-50 fix: something\n", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="lint error"),
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Lint failed" in result.output
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||