Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84958fab7 | ||
|
|
c0238e75df | ||
|
|
d4ddbd7e7a | ||
|
|
08b993fc4a | ||
|
|
bee730a52f | ||
|
|
d0a4a774a0 | ||
|
|
882f9805ed | ||
|
|
a85e0baaea | ||
|
|
663572768b | ||
|
|
1f2533872d | ||
|
|
cb7e9dbc7e | ||
|
|
f5081e10b1 | ||
|
|
cabc0d1adc | ||
|
|
7c1ecd6ff9 | ||
|
|
5063f659bc | ||
|
|
96c77a0ba4 | ||
|
|
40dd578d89 | ||
|
|
7ea9b4a96b | ||
|
|
08ceaf484f | ||
|
|
fb6b0fda1d | ||
|
|
b81a418d07 | ||
|
|
58261f7d1a | ||
|
|
85e38f37fd | ||
|
|
08b573e2ed | ||
|
|
e45a546c16 | ||
|
|
41c631d5f5 | ||
|
|
e271c79e93 | ||
|
|
f4305821f1 | ||
|
|
e4f40223d2 | ||
|
|
06e80516d4 | ||
|
|
f1adf22c3e | ||
|
|
91216da1a4 | ||
|
|
f9836208df | ||
|
|
0f0f0b683a | ||
|
|
54f687f1bf | ||
|
|
44c906a5e6 | ||
|
|
a3d528f802 | ||
|
|
e3a7afc0b0 |
@@ -0,0 +1,20 @@
|
|||||||
|
.venv/
|
||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
*.egg-info/
|
||||||
|
__pycache__/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
activate.sh
|
||||||
|
activate.fish
|
||||||
|
activate.zsh
|
||||||
|
hooks/
|
||||||
|
.devin/
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
name: Build Images
|
||||||
|
|
||||||
|
# Builds and pushes pre-built Docker runner images to the Gitea registry.
|
||||||
|
# These images eliminate the 40-120s setup tax on every CI job by baking
|
||||||
|
# devx and all dependencies into the image.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - On push to master (after post-merge release completes)
|
||||||
|
# - Manually via workflow_dispatch
|
||||||
|
#
|
||||||
|
# The workflow builds 3 tier images in sequence:
|
||||||
|
# ci-base → ci-quality → ci-full
|
||||||
|
#
|
||||||
|
# Each tier builds FROM the previous one, so they must be built in order.
|
||||||
|
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- docker/**
|
||||||
|
- pyproject.toml
|
||||||
|
- src/devx/**
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect-type:
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
is-release: ${{ steps.check.outputs.is-release }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
- name: Set up environment
|
||||||
|
run: make setup-ci
|
||||||
|
- name: Check if this is a release commit
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.detect_release_commit
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
needs: [detect-type]
|
||||||
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
run: make setup-release
|
||||||
|
- name: Docker registry login
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
echo "$REPO_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
- name: Build and push tier images
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
# Build ci-base first (it's the base for ci-quality and ci-full)
|
||||||
|
python3 -m devx.tools.build_image \
|
||||||
|
--dockerfile docker/ci-base/Dockerfile \
|
||||||
|
--name oblachno-oss/runner-images/ci-base \
|
||||||
|
--tag latest \
|
||||||
|
--registry git.oblachno.oblachno.fyi \
|
||||||
|
--push --pull
|
||||||
|
# Build ci-quality (FROM ci-base-latest)
|
||||||
|
python3 -m devx.tools.build_image \
|
||||||
|
--dockerfile docker/ci-quality/Dockerfile \
|
||||||
|
--name oblachno-oss/runner-images/ci-quality \
|
||||||
|
--tag latest \
|
||||||
|
--registry git.oblachno.oblachno.fyi \
|
||||||
|
--push
|
||||||
|
# Build ci-full (FROM ci-quality-latest)
|
||||||
|
python3 -m devx.tools.build_image \
|
||||||
|
--dockerfile docker/ci-full/Dockerfile \
|
||||||
|
--name oblachno-oss/runner-images/ci-full \
|
||||||
|
--tag latest \
|
||||||
|
--registry git.oblachno.oblachno.fyi \
|
||||||
|
--push
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
python3 -m devx.ci.notify_failure \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--run-id "${{ github.run_id }}" \
|
||||||
|
--workflow "build-images/build-and-push" \
|
||||||
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
needs: [build-and-push]
|
||||||
|
if: always() && needs.build-and-push.result == 'success'
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
- name: Set up environment
|
||||||
|
run: make setup-ci
|
||||||
|
- name: Clean up old image versions
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.tools.clean_images \
|
||||||
|
--owner oblachno-oss \
|
||||||
|
--name oblachno-oss/runner-images/ci-base \
|
||||||
|
--name oblachno-oss/runner-images/ci-quality \
|
||||||
|
--name oblachno-oss/runner-images/ci-full \
|
||||||
|
--keep 2
|
||||||
@@ -121,8 +121,13 @@ jobs:
|
|||||||
auto-merge:
|
auto-merge:
|
||||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||||
# from the branch name, validates the PR title, and squash-merges.
|
# from the branch name, validates the PR title, and squash-merges.
|
||||||
|
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
|
||||||
needs: [quality, detect-changes, pr-review]
|
needs: [quality, detect-changes, pr-review]
|
||||||
if: github.event_name == 'pull_request'
|
if: >-
|
||||||
|
always() &&
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
needs.quality.result == 'success' &&
|
||||||
|
needs.pr-review.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -130,10 +135,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.REPO_TOKEN }}
|
token: ${{ secrets.REPO_TOKEN }}
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
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
|
- name: Squash merge with task ID
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
@@ -145,6 +148,7 @@ jobs:
|
|||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
python3 -m devx.ci.auto_merge \
|
python3 -m devx.ci.auto_merge \
|
||||||
"$HEAD_REF" \
|
"$HEAD_REF" \
|
||||||
"$PR_TITLE" \
|
"$PR_TITLE" \
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
name: Post-merge
|
name: Post-merge
|
||||||
|
|
||||||
# Runs on every push to master. A single workflow with conditional jobs
|
# Runs on every push to master. A single workflow with conditional jobs
|
||||||
# replaces separate workflows for release, wiki sync, badges, and
|
# for release, publish, wiki sync, badges, and Vikunja task updates.
|
||||||
# Vikunja task updates.
|
|
||||||
#
|
#
|
||||||
# Job dependency graph:
|
# Job dependency graph:
|
||||||
#
|
#
|
||||||
# detect-type ──┬── release (skip if release commit)
|
# detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||||
|
# ├── release (skip if release commit)
|
||||||
|
# │ └── publish (needs release — builds & publishes to PyPI)
|
||||||
# ├── badges (ALWAYS runs — even on release commits)
|
# ├── badges (ALWAYS runs — even on release commits)
|
||||||
# ├── configure-repo (independent — skip if release commit)
|
# ├── configure-repo (independent — skip if release commit)
|
||||||
# ├── sync-wiki (needs release — skip if release commit/fails)
|
# ├── sync-wiki (skip if release commit — runs for ALL merges)
|
||||||
# └── vikunja (needs release — skip if release commit/fails)
|
# └── vikunja (skip if release commit — runs for ALL merges)
|
||||||
#
|
#
|
||||||
# sync-wiki and vikunja depend on release succeeding so that the wiki
|
# sync-wiki and vikunja run for ALL non-release commits, not just when
|
||||||
# and task tracker are only updated when the code is actually released.
|
# release succeeds. This ensures the wiki and task tracker are updated
|
||||||
# If release fails, they are skipped to avoid leaving the wiki or
|
# even for infrastructure-only changes (docs, CI config, etc.).
|
||||||
# Vikunja in an inconsistent state with the codebase on master.
|
|
||||||
#
|
#
|
||||||
# The badges job depends on release so it picks up the latest version
|
# The badges job uses `if: always()` with no is-release condition so it
|
||||||
# number. It uses `if: always()` with no is-release condition so it
|
# runs on every push to master, including release commits. This ensures
|
||||||
# runs on every push to master, including release commits. This
|
# badges (tests, coverage, version, etc.) are always current.
|
||||||
# ensures badges (tests, coverage, version, etc.) are always current.
|
|
||||||
#
|
#
|
||||||
# When release creates a "release: vX.Y.Z" commit, the release
|
# When release creates a "release: vX.Y.Z" commit and tag, the publish
|
||||||
# commit's post-merge run still updates badges (version badge picks
|
# job (which depends on release) builds and publishes the package to the
|
||||||
# up the new version). Other jobs skip. The tag push triggers publish.yml.
|
# Gitea PyPI registry. The release commit's post-merge run still updates
|
||||||
|
# badges (version badge picks up the new version). Other jobs skip.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -40,15 +40,15 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
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
|
- name: Check if this is a release commit
|
||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.ci.detect_release_commit
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.detect_release_commit
|
||||||
|
|
||||||
validate-commit-msg:
|
validate-commit-msg:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -59,14 +59,13 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages click python-dotenv
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Validate latest commit message
|
- name: Validate latest commit message
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
git log -1 --format=%B > commit-msg.txt
|
git log -1 --format=%B > commit-msg.txt
|
||||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||||
rm -f commit-msg.txt
|
rm -f commit-msg.txt
|
||||||
@@ -76,6 +75,8 @@ jobs:
|
|||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.release-tag.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -90,26 +91,20 @@ jobs:
|
|||||||
git config user.name "devx-ci-bot"
|
git config user.name "devx-ci-bot"
|
||||||
git config user.email "devx-ci-bot@oblachno.fyi"
|
git config user.email "devx-ci-bot@oblachno.fyi"
|
||||||
- name: Run release
|
- name: Run release
|
||||||
|
id: release-tag
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.release
|
python3 -m devx.ci.release
|
||||||
- name: Publish release
|
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
|
||||||
env:
|
if: steps.release-tag.outputs.tag == ''
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
|
||||||
PYTHONPATH: src
|
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
if [ -n "$tag" ]; then
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
if [ -z "$TAG" ]; then
|
|
||||||
echo "No tag found — skipping publish"
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
echo "Publishing release $TAG (idempotent — skips if already published)..."
|
|
||||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
@@ -118,17 +113,49 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
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 \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/release" \
|
--workflow "post-merge/release" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [release]
|
||||||
|
if: needs.release.outputs.tag != ''
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
run: make setup-release
|
||||||
|
- name: Build and publish release
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}"
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
python3 -m devx.ci.notify_failure \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--run-id "${{ github.run_id }}" \
|
||||||
|
--workflow "post-merge/publish" \
|
||||||
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
sync-wiki:
|
sync-wiki:
|
||||||
needs: [detect-type, release]
|
needs: [detect-type]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -159,7 +186,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
badges:
|
badges:
|
||||||
needs: [detect-type, release]
|
needs: [detect-type]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -195,7 +222,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
vikunja:
|
vikunja:
|
||||||
needs: [detect-type, release]
|
needs: [detect-type]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -203,16 +230,16 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Update Vikunja task
|
- name: Update Vikunja task
|
||||||
env:
|
env:
|
||||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
@@ -220,9 +247,6 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
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 \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
@@ -236,15 +260,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
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
|
- name: Ensure branch protection and labels
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
@@ -252,9 +276,6 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
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 \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
name: Publish Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Tag to publish (e.g. v0.9.11)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: docker
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Install CI tools
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
|
|
||||||
- name: Configure tea login
|
|
||||||
env:
|
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
|
||||||
tea login default devx || true
|
|
||||||
- name: Build and publish release
|
|
||||||
env:
|
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
|
||||||
PYTHONPATH: src
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
|
|
||||||
- name: Notify on failure
|
|
||||||
if: failure()
|
|
||||||
env:
|
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
|
||||||
PYTHONPATH: src
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
python3 -m devx.ci.notify_failure \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--run-id "${{ github.run_id }}" \
|
|
||||||
--workflow "publish" \
|
|
||||||
--commit "${{ github.sha }}"
|
|
||||||
@@ -57,6 +57,7 @@ src/devx/
|
|||||||
│ ├── release.py # Automated versioning, tagging, changelog
|
│ ├── release.py # Automated versioning, tagging, changelog
|
||||||
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
||||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||||
|
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||||
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
||||||
│ ├── detect_release_commit.py # Detect release commits on master
|
│ ├── detect_release_commit.py # Detect release commits on master
|
||||||
@@ -66,20 +67,27 @@ src/devx/
|
|||||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
||||||
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
||||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||||
│ ├── check_translations.py # Translation completeness check
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ └── doc_coverage.py # Documentation coverage check
|
│ └── doc_coverage.py # Documentation coverage check
|
||||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
|
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
|
||||||
|
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
|
||||||
|
│ ├── build_image.py # Build and push Docker images to Gitea registry
|
||||||
|
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
|
||||||
│ ├── check_test_speed.py # Measure unit test execution time
|
│ ├── check_test_speed.py # Measure unit test execution time
|
||||||
|
│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs)
|
||||||
|
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
|
||||||
|
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
||||||
|
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
||||||
│ ├── configure_repo.py # Branch protection and label setup
|
│ ├── configure_repo.py # Branch protection and label setup
|
||||||
│ └── generate_badges.py # Badge SVG generation
|
│ └── generate_badges.py # Badge SVG generation
|
||||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
|
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
@@ -176,7 +184,7 @@ After a PR is merged to master, the **post-merge workflow**
|
|||||||
|
|
||||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
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 commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||||
release commits.
|
release commits (except badges).
|
||||||
|
|
||||||
2. **release** — Runs `python -m devx.ci.release` which:
|
2. **release** — Runs `python -m devx.ci.release` which:
|
||||||
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
|
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
|
||||||
@@ -188,14 +196,20 @@ After a PR is merged to master, the **post-merge workflow**
|
|||||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||||
- Pushes both the commit and tag to master
|
- Pushes both the commit and tag to master
|
||||||
|
|
||||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
|
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
|
||||||
|
non-release commits (not just when release succeeds), so docs-only
|
||||||
|
changes still update the wiki.
|
||||||
|
|
||||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||||
|
Uses `if: always()` so it runs on every push, including release commits.
|
||||||
|
|
||||||
5. **vikunja** — Marks the corresponding Vikunja task as done.
|
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
|
||||||
|
non-release commits (not just when release succeeds), so infrastructure-only
|
||||||
|
changes still update the task tracker.
|
||||||
|
|
||||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
6. **publish** — Runs after release succeeds (needs: release). Builds and
|
||||||
which builds and publishes the package to the Gitea PyPI registry.
|
publishes the package to the Gitea PyPI registry. Gets the tag from the
|
||||||
|
release job's `tag` output (written via `GITHUB_OUTPUT`).
|
||||||
|
|
||||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||||
|
|
||||||
@@ -310,6 +324,25 @@ auto-merge:
|
|||||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### LPT Test Distribution Algorithm
|
||||||
|
|
||||||
|
`distribute_molecule` and `distribute_files` use **LPT (Longest Processing
|
||||||
|
Time first)** scheduling instead of naive round-robin. This produces a more
|
||||||
|
balanced distribution when test items have varying costs:
|
||||||
|
|
||||||
|
1. **Weight estimation**: Each item is assigned a weight:
|
||||||
|
- Molecule scenarios: heuristic by name (`nextcloud`=10, `gitea`=8,
|
||||||
|
`binary`=2, default=3). See `_SCENARIO_WEIGHTS` in
|
||||||
|
`distribute_molecule.py`.
|
||||||
|
- Integration test files: weight by file size in bytes (as a proxy
|
||||||
|
for test runtime).
|
||||||
|
2. **LPT assignment**: Items are sorted by weight (descending), then
|
||||||
|
each is assigned to the runner with the least total weight.
|
||||||
|
|
||||||
|
This ensures heavy scenarios (e.g. `nextcloud`) are spread across
|
||||||
|
different runners rather than clustered on one, reducing the
|
||||||
|
longest-runner time from ~16 min to ~11 min with 6 runners.
|
||||||
|
|
||||||
## Config System
|
## Config System
|
||||||
|
|
||||||
devx uses environment variables with `.env` file fallback for configuration.
|
devx uses environment variables with `.env` file fallback for configuration.
|
||||||
@@ -334,6 +367,133 @@ Projects using devx can override the default API URLs and language by setting
|
|||||||
`DEVX_*` environment variables or entries in their `.env` file. The config
|
`DEVX_*` environment variables or entries in their `.env` file. The config
|
||||||
system loads `.env` automatically via `python-dotenv`.
|
system loads `.env` automatically via `python-dotenv`.
|
||||||
|
|
||||||
|
### pyproject.toml [tool.devx] Configuration
|
||||||
|
|
||||||
|
In addition to `DEVX_` env vars, several devx tools read configuration from
|
||||||
|
the `[tool.devx]` section in `pyproject.toml`. This allows per-project
|
||||||
|
customization without environment variables.
|
||||||
|
|
||||||
|
**Base config** (`[tool.devx]`):
|
||||||
|
- `task_prefix` — Task ID prefix (e.g. `"DEVX"`, `"GRM"`, `"OBL-INFRA"`)
|
||||||
|
- `vikunja_project_id` — Vikunja project ID
|
||||||
|
- `repo_owner` / `repo_name` — Gitea repository coordinates
|
||||||
|
- `gitea_api_url` / `vikunja_api_url` — API endpoints
|
||||||
|
|
||||||
|
**Tool-specific config**:
|
||||||
|
- `[tool.devx.check_mutable_globals]` — `scan_dirs`, `skip_dirs`, `known_safe`
|
||||||
|
- `[tool.devx.check_test_coverage]` — `rules` (source_pattern → test_paths mapping), `skip_patterns`
|
||||||
|
- `[tool.devx.check_agent_docs]` — `scan_dirs`, `deleted_files`, `deprecated_patterns`, `legitimate_indicators`
|
||||||
|
|
||||||
|
## devx.mak — Shared Makefile Fragment
|
||||||
|
|
||||||
|
`devx.mak` provides common Makefile targets that projects can include
|
||||||
|
via `-include $(DEVX_MAK)`. This eliminates Makefile duplication across
|
||||||
|
projects.
|
||||||
|
|
||||||
|
**Available targets** (all prefixed with `devx-`):
|
||||||
|
|
||||||
|
| Target | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `devx-create-task` | Create a Vikunja task |
|
||||||
|
| `devx-create-pr` | Create a PR with auto-derived title |
|
||||||
|
| `devx-push` | Push current branch to origin |
|
||||||
|
| `devx-push-with-pr` | Push and create PR in one step |
|
||||||
|
| `devx-check-config` | Validate devx configuration |
|
||||||
|
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||||
|
| `devx-env` | Create .env from .env.example |
|
||||||
|
| `devx-venv` | Create Python venv with version check |
|
||||||
|
| `devx-activate-scripts` | Create shell/fish/zsh activate scripts |
|
||||||
|
| `devx-install-hooks` | Set git hooks path to hooks/ |
|
||||||
|
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||||
|
| `devx-install-checkmake` | Install checkmake (Makefile linter) |
|
||||||
|
| `devx-checkmake` | Lint Makefiles with checkmake |
|
||||||
|
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
|
||||||
|
| `devx-workflow-dryrun` | Dry-run all workflows (act_runner) |
|
||||||
|
| `devx-workflow-dryrun-safe` | Best-effort dry-run (skips if act_runner missing) |
|
||||||
|
| `devx-workflow-check` | Static lint + dry-run |
|
||||||
|
| `devx-notify-failure` | Create Gitea issue on CI failure |
|
||||||
|
| `devx-lint-ruff` | Run ruff check |
|
||||||
|
| `devx-lint-format` | Run ruff format --check |
|
||||||
|
| `devx-typecheck` | Run pyright |
|
||||||
|
| `devx-lint-bandit` | Run bandit security scan |
|
||||||
|
| `devx-lint-deps` | Check dependencies for vulnerabilities (pip-audit) |
|
||||||
|
| `devx-lint` | Run all lint targets |
|
||||||
|
| `devx-test-unit` | Run unit tests without coverage |
|
||||||
|
| `devx-pytest-cov` | Run pytest with coverage enforcement |
|
||||||
|
| `devx-check-mutable-globals` | Scan for mutable path globals |
|
||||||
|
| `devx-check-dep-docs` | Validate pyproject.toml deps are documented |
|
||||||
|
| `devx-check-test-coverage` | Check changed files have corresponding tests |
|
||||||
|
| `devx-check-docs` | Validate docs for stale references |
|
||||||
|
| `devx-check-test-speed` | Verify test suite timing |
|
||||||
|
| `devx-pre-push` | Run lint + tests before push |
|
||||||
|
| `devx-clean` | Remove caches, build artifacts, coverage data |
|
||||||
|
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
|
||||||
|
| `devx-build-images` | Build Docker images from manifest (no push) |
|
||||||
|
| `devx-push-images` | Build and push Docker images to Gitea registry |
|
||||||
|
| `devx-build-images-dry-run` | Show what would be built/pushed |
|
||||||
|
| `devx-clean-images` | Delete old image versions (keep last 2 + latest) |
|
||||||
|
|
||||||
|
**Variables** (set BEFORE including devx.mak):
|
||||||
|
- `DEVX_PYTHON` — Python executable (default: `python3`)
|
||||||
|
- `DEVX_VENV` — venv directory (default: `.venv`)
|
||||||
|
- `DEVX_BIN` — venv bin directory (default: `$(DEVX_VENV)/bin`)
|
||||||
|
- `DEVX_LINT_PATHS` — paths for ruff/bandit (default: `src/ tests/`)
|
||||||
|
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
|
||||||
|
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
|
||||||
|
- `DEVX_PR_BASE` — PR base branch (default: `master`)
|
||||||
|
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
|
||||||
|
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
|
||||||
|
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
|
||||||
|
|
||||||
|
## Pre-built Docker Runner Images
|
||||||
|
|
||||||
|
devx builds and publishes three tier images to the Gitea container registry
|
||||||
|
to eliminate the 40-120s setup tax on every CI job:
|
||||||
|
|
||||||
|
| Image | Contains | Used by jobs |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
|
||||||
|
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges |
|
||||||
|
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
|
||||||
|
|
||||||
|
**Build process** (in `build-images.yml` workflow):
|
||||||
|
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
|
||||||
|
2. `ci-quality` builds FROM `ci-base-latest`
|
||||||
|
3. `ci-full` builds FROM `ci-quality-latest`
|
||||||
|
|
||||||
|
Each image is tagged `latest` and pushed to
|
||||||
|
`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:<tier>-latest`.
|
||||||
|
|
||||||
|
**Using images in workflows**:
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: docker
|
||||||
|
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-quality-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up environment
|
||||||
|
run: make setup-image # links /opt/venv, installs project (no-deps)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Image build/push tools** (tested Python modules):
|
||||||
|
- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest
|
||||||
|
- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest)
|
||||||
|
|
||||||
|
**Usage in project Makefile**:
|
||||||
|
```makefile
|
||||||
|
DEVX_PYTHON := $(BIN)/python
|
||||||
|
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||||
|
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||||
|
2>/dev/null)
|
||||||
|
-include $(DEVX_MAK)
|
||||||
|
|
||||||
|
# Aliases for project-specific names
|
||||||
|
lint-ruff: devx-lint-ruff
|
||||||
|
workflow-lint: devx-workflow-lint
|
||||||
|
create-task: devx-create-task
|
||||||
|
```
|
||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||||
|
|||||||
@@ -2,6 +2,60 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.20.0] - 2026-06-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add pre-built Docker runner images and tested image build/push tools
|
||||||
|
|
||||||
|
## [0.19.3] - 2026-06-26
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Make molecule weights configurable via pyproject.toml
|
||||||
|
|
||||||
|
## [0.19.2] - 2026-06-26
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Calibrate molecule weights from actual CI execution times
|
||||||
|
|
||||||
|
## [0.19.1] - 2026-06-26
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Consolidate publish.yml into post-merge.yml
|
||||||
|
|
||||||
|
## [0.19.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add skip_ref_prefixes config to check_agent_docs
|
||||||
|
|
||||||
|
## [0.18.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Extract generic tools into devx, expand devx.mak, remove personal references
|
||||||
|
|
||||||
|
## [0.17.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release
|
||||||
|
|
||||||
|
## [0.16.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Single-source-of-truth config via [tool.devx] in pyproject.toml
|
||||||
|
|
||||||
|
## [0.15.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add create-task, create-pr, pre-push-check tools and devx.mak fragment
|
||||||
|
|
||||||
## [0.14.2] - 2026-06-26
|
## [0.14.2] - 2026-06-26
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -208,8 +208,8 @@ If you develop a new program, and you want it to be of the greatest possible use
|
|||||||
|
|
||||||
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.
|
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
|
devx
|
||||||
Copyright (C) 2026 emil
|
Copyright (C) 2026 oblachno-oss
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ 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:
|
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
|
devx Copyright (C) 2026 oblachno-oss
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
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.
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.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
|
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
|
||||||
|
|
||||||
PYTHON := python3
|
PYTHON := python3
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -30,6 +30,11 @@ setup-release: $(VENV)/bin/activate .env
|
|||||||
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit
|
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit
|
||||||
|
|
||||||
|
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
|
||||||
|
setup-image:
|
||||||
|
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
|
||||||
|
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||||
|
|
||||||
.env:
|
.env:
|
||||||
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
|
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
|
||||||
|
|
||||||
@@ -52,48 +57,70 @@ install-tools: $(VENV)/bin/activate
|
|||||||
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
||||||
$(BIN)/python -m devx.tools.install_tools
|
$(BIN)/python -m devx.tools.install_tools
|
||||||
|
|
||||||
lint-ruff:
|
# --- devx.mak integration ----------------------------------------------------
|
||||||
$(BIN)/ruff check src/ tests/
|
# Include shared targets from the devx package itself (workflow-lint,
|
||||||
|
# notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||||
|
# Since devx IS the package, we can include its own devx.mak.
|
||||||
|
DEVX_PYTHON := $(BIN)/python
|
||||||
|
DEVX_VENV := $(VENV)
|
||||||
|
DEVX_BIN := $(BIN)
|
||||||
|
DEVX_LINT_PATHS := src/ tests/
|
||||||
|
DEVX_COV_PKG := src/devx
|
||||||
|
DEVX_TEST_PATHS := tests/
|
||||||
|
|
||||||
lint-format:
|
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||||
$(BIN)/ruff format --check src/ tests/
|
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||||
|
2>/dev/null)
|
||||||
|
-include $(DEVX_MAK)
|
||||||
|
|
||||||
typecheck:
|
# Aliases — project-specific names map to devx.mak targets
|
||||||
$(BIN)/pyright
|
lint-ruff: devx-lint-ruff
|
||||||
|
lint-format: devx-lint-format
|
||||||
lint-bandit:
|
typecheck: devx-typecheck
|
||||||
$(BIN)/bandit -r src/
|
lint-bandit: devx-lint-bandit
|
||||||
|
lint-deps: devx-lint-deps
|
||||||
lint: lint-ruff lint-format typecheck lint-bandit
|
lint: devx-lint
|
||||||
|
workflow-lint: devx-workflow-lint
|
||||||
lint-deps:
|
workflow-dryrun: devx-workflow-dryrun
|
||||||
@echo "Checking dependencies for known vulnerabilities..."
|
workflow-dryrun-safe: devx-workflow-dryrun-safe
|
||||||
@.venv/bin/python -m ensurepip 2>/dev/null || true
|
workflow-check: devx-workflow-check
|
||||||
@PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python .venv/bin/pip-audit --desc --skip-editable 2>&1 || true
|
notify-failure: devx-notify-failure
|
||||||
|
checkmake: devx-checkmake
|
||||||
|
check-mutable-globals: devx-check-mutable-globals
|
||||||
|
check-dep-docs: devx-check-dep-docs
|
||||||
|
check-test-speed: devx-check-test-speed
|
||||||
|
check-test-coverage: devx-check-test-coverage
|
||||||
|
check-docs: devx-check-docs
|
||||||
|
create-task: devx-create-task
|
||||||
|
create-pr: devx-create-pr
|
||||||
|
push-with-pr: devx-push-with-pr
|
||||||
|
git-push: devx-push
|
||||||
|
|
||||||
lint-all: lint workflow-lint
|
lint-all: lint workflow-lint
|
||||||
|
@echo "[lint-all] All linting checks passed."
|
||||||
|
|
||||||
workflow-lint:
|
test-unit: devx-test-unit
|
||||||
@command -v actionlint >/dev/null 2>&1 || { echo "actionlint not found."; exit 1; }
|
|
||||||
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
|
|
||||||
|
|
||||||
workflow-dryrun:
|
pytest-cov: devx-pytest-cov
|
||||||
@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
|
test: pytest-cov
|
||||||
|
|
||||||
clean:
|
pre-push: lint-all pytest-cov
|
||||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
@echo "[pre-push] All checks passed. Proceeding with push."
|
||||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
|
||||||
rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/
|
clean: devx-clean
|
||||||
|
@echo "[clean] Done."
|
||||||
|
|
||||||
|
# ── Docker image management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build-images: devx-build-images
|
||||||
|
@echo "[build-images] Done."
|
||||||
|
|
||||||
|
push-images: devx-push-images
|
||||||
|
@echo "[push-images] Done."
|
||||||
|
|
||||||
|
build-images-dry-run: devx-build-images-dry-run
|
||||||
|
@echo "[build-images-dry-run] Done."
|
||||||
|
|
||||||
|
clean-images: devx-clean-images
|
||||||
|
@echo "[clean-images] Done."
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ci-base — lightweight image for CI jobs that only need devx core + tea.
|
||||||
|
#
|
||||||
|
# Used by: detect-type, detect-changes, validate-commit-msg, pr-review,
|
||||||
|
# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners,
|
||||||
|
# molecule-report, discover-integration-runners
|
||||||
|
#
|
||||||
|
# Jobs using this image: setup is instant (ln -s /opt/venv .venv)
|
||||||
|
# No pip install needed — devx and all deps are pre-installed.
|
||||||
|
|
||||||
|
FROM gitea/runner-images:ubuntu-latest
|
||||||
|
|
||||||
|
# Create a virtual environment with all deps pre-installed
|
||||||
|
RUN python3 -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# Install devx from local source (build context = devx repo root)
|
||||||
|
COPY . /tmp/devx
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
|
||||||
|
&& pip install --no-cache-dir /tmp/devx[ci] \
|
||||||
|
&& rm -rf /tmp/devx
|
||||||
|
|
||||||
|
# Install tea CLI (for Gitea API operations in CI)
|
||||||
|
RUN python3 -m devx.tools.install_tools --tool tea
|
||||||
|
|
||||||
|
# Workspace directory (actions/checkout mounts repo here)
|
||||||
|
WORKDIR /workspace
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ci-full — heaviest image, includes everything for release, molecule, deploy.
|
||||||
|
#
|
||||||
|
# Used by: release, publish, release-dry-run, molecule-tests,
|
||||||
|
# provision-infra, deploy-observability, provision-zitadel,
|
||||||
|
# deploy-customer, integration-tests
|
||||||
|
#
|
||||||
|
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
|
||||||
|
# git-cliff, and OpenTofu.
|
||||||
|
|
||||||
|
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-quality-latest
|
||||||
|
|
||||||
|
# Install devx[release,molecule,deploy] from local source
|
||||||
|
COPY . /tmp/devx
|
||||||
|
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||||
|
&& rm -rf /tmp/devx
|
||||||
|
|
||||||
|
# Install git-cliff (changelog generator for release job)
|
||||||
|
RUN python3 -m devx.tools.install_tools --tool git-cliff
|
||||||
|
|
||||||
|
# Install OpenTofu (for infra deploy jobs)
|
||||||
|
RUN ARCH=$(uname -m | sed 's/x86_64/amd64') \
|
||||||
|
&& VERSION=1.12.3 \
|
||||||
|
&& curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin tofu
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# ci-quality — image for lint, type-checking, badge generation.
|
||||||
|
#
|
||||||
|
# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges
|
||||||
|
# runs ruff/pyright/bandit to produce quality badge)
|
||||||
|
#
|
||||||
|
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
|
||||||
|
|
||||||
|
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-base-latest
|
||||||
|
|
||||||
|
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
|
||||||
|
COPY . /tmp/devx
|
||||||
|
RUN pip install --no-cache-dir /tmp/devx[lint] \
|
||||||
|
&& rm -rf /tmp/devx
|
||||||
|
|
||||||
|
# Install CI/CD binary tools
|
||||||
|
RUN python3 -m devx.tools.install_tools --tool actionlint \
|
||||||
|
&& python3 -m devx.tools.install_checkmake
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "oblachno-oss/runner-images/ci-base",
|
||||||
|
"dockerfile": "docker/ci-base/Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tags": ["latest"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "oblachno-oss/runner-images/ci-quality",
|
||||||
|
"dockerfile": "docker/ci-quality/Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tags": ["latest"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "oblachno-oss/runner-images/ci-full",
|
||||||
|
"dockerfile": "docker/ci-full/Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tags": ["latest"]
|
||||||
|
}
|
||||||
|
]
|
||||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
+28
-8
@@ -26,14 +26,13 @@ devx = "devx.cli:cli"
|
|||||||
version = {attr = "devx.__version__"}
|
version = {attr = "devx.__version__"}
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# Minimal deps for CI scripts that only need click/dotenv/requests
|
# Test runners (pytest + coverage + parallel execution)
|
||||||
ci = [
|
ci = [
|
||||||
"pytest>=9.1.0",
|
"pytest>=9.1.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
"build>=1.5.0",
|
"pytest-xdist>=3.8",
|
||||||
"twine>=6.2.0",
|
|
||||||
]
|
]
|
||||||
# Lint and type-checking tools (quality job)
|
# Lint and type-checking tools (quality job, badge generation)
|
||||||
lint = [
|
lint = [
|
||||||
"ruff>=0.15.17",
|
"ruff>=0.15.17",
|
||||||
"pyright>=1.1.410",
|
"pyright>=1.1.410",
|
||||||
@@ -41,16 +40,30 @@ lint = [
|
|||||||
"pip-audit>=2.10",
|
"pip-audit>=2.10",
|
||||||
"pre-commit>=4.6.0",
|
"pre-commit>=4.6.0",
|
||||||
]
|
]
|
||||||
# Molecule testing (optional — for projects with Ansible roles)
|
# Release tools (build + publish to PyPI/Gitea registry)
|
||||||
|
release = [
|
||||||
|
"build>=1.5.0",
|
||||||
|
"twine>=6.2.0",
|
||||||
|
]
|
||||||
|
# Molecule testing (for projects with Ansible roles)
|
||||||
molecule = [
|
molecule = [
|
||||||
"molecule>=26.4.0",
|
"molecule>=26.4.0",
|
||||||
"molecule-docker>=2.1.0",
|
"molecule-docker>=2.1.0",
|
||||||
"ansible-lint>=26.4.0",
|
"ansible-lint>=26.4.0",
|
||||||
"ansible>=14.0.0",
|
"ansible-core>=2.15,<2.17",
|
||||||
|
]
|
||||||
|
# Deploy tools (for infra staging/production deployments)
|
||||||
|
deploy = [
|
||||||
|
"ansible-core>=2.15,<2.17",
|
||||||
|
"boto3>=1.34",
|
||||||
|
"docker>=7.0",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"cryptography>=41.0",
|
||||||
]
|
]
|
||||||
# Full dev environment (local development)
|
# Full dev environment (local development)
|
||||||
dev = [
|
dev = [
|
||||||
"devx[ci,lint]",
|
"devx[ci,lint,release,molecule]",
|
||||||
"build>=1.3.0",
|
"build>=1.3.0",
|
||||||
"twine>=6.2.0",
|
"twine>=6.2.0",
|
||||||
]
|
]
|
||||||
@@ -59,7 +72,7 @@ dev = [
|
|||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
devx = ["translations.json"]
|
devx = ["translations.json", "make/*.mak"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
@@ -96,6 +109,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
|
|||||||
# Rule priority (first match wins):
|
# Rule priority (first match wins):
|
||||||
# 1. user_facing_overrides (safety — highest priority)
|
# 1. user_facing_overrides (safety — highest priority)
|
||||||
# 2. infrastructure_overrides (explicit per-file)
|
# 2. infrastructure_overrides (explicit per-file)
|
||||||
|
# Project-specific devx configuration (read by devx.config)
|
||||||
|
[tool.devx]
|
||||||
|
task_prefix = "DEVX"
|
||||||
|
vikunja_project_id = 8
|
||||||
|
repo_owner = "oblachno-oss"
|
||||||
|
repo_name = "devx"
|
||||||
|
|
||||||
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||||
# 4. Default: user-facing (safe)
|
# 4. Default: user-facing (safe)
|
||||||
[tool.devx.classify]
|
[tool.devx.classify]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.14.2"
|
__version__ = "0.20.0"
|
||||||
|
|||||||
@@ -192,6 +192,21 @@ class GiteaClient:
|
|||||||
r = self._request("GET", f"/pulls/{pr_number}")
|
r = self._request("GET", f"/pulls/{pr_number}")
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def create_pr(self, title: str, head: str, base: str = "master", body: str = "") -> dict[str, Any]:
|
||||||
|
"""Create a pull request and return the PR dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: PR title.
|
||||||
|
head: Head branch name.
|
||||||
|
base: Base branch name (default: master).
|
||||||
|
body: PR description (markdown).
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"title": title, "head": head, "base": base}
|
||||||
|
if body:
|
||||||
|
payload["body"] = body
|
||||||
|
r = self._request("POST", "/pulls", json=payload)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
|
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
|
||||||
"""List pull requests, optionally filtered by state.
|
"""List pull requests, optionally filtered by state.
|
||||||
|
|
||||||
@@ -353,6 +368,21 @@ class VikunjaClient:
|
|||||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
|
||||||
|
"""Create a task in a project and return the created task dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: Target Vikunja project ID.
|
||||||
|
title: Task title (required, non-empty).
|
||||||
|
description: Task description (HTML supported, optional).
|
||||||
|
"""
|
||||||
|
r = self._request(
|
||||||
|
"PUT",
|
||||||
|
f"/projects/{project_id}/tasks",
|
||||||
|
json={"title": title, "description": description},
|
||||||
|
)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def post_comment(self, task_id: int, comment: str) -> None:
|
def post_comment(self, task_id: int, comment: str) -> None:
|
||||||
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pre-merge validation gate for auto-merge preconditions.
|
||||||
|
|
||||||
|
Validates that a PR satisfies auto-merge requirements BEFORE expensive
|
||||||
|
jobs (molecule tests, staging deploy) run. This catches issues early:
|
||||||
|
|
||||||
|
1. Branch name contains a task ID (e.g., ``DEVX-256-fix-foo``).
|
||||||
|
2. PR title follows ``{PREFIX}-N: <title>`` format.
|
||||||
|
3. PR title task ID matches the branch task ID.
|
||||||
|
4. PR title matches the Vikunja task title (requires ``VIKUNJA_TOKEN``).
|
||||||
|
5. Branch is not behind master (would trigger a rebase retry cycle).
|
||||||
|
|
||||||
|
Exit code 0 = ready for auto-merge (preconditions satisfied).
|
||||||
|
Exit code 1 = NOT ready — fix issues before pushing.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# CI (with VIKUNJA_TOKEN and REPO_TOKEN):
|
||||||
|
python3 -m devx.ci.check_auto_merge_ready \\
|
||||||
|
--branch "$HEAD_REF" \\
|
||||||
|
--pr-title "$PR_TITLE" \\
|
||||||
|
--repo "$REPOSITORY" \\
|
||||||
|
--pr-number "$PR_NUMBER"
|
||||||
|
|
||||||
|
# Local (pre-push hook, no PR yet — validates branch + title format only):
|
||||||
|
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
|
||||||
|
# Local (with PR number, fetches title from Gitea):
|
||||||
|
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)" \\
|
||||||
|
--repo owner/repo --pr-number 123
|
||||||
|
|
||||||
|
If ``VIKUNJA_TOKEN`` is not set, the Vikunja title match check is
|
||||||
|
skipped (with a warning) — this allows local pre-push hooks to run
|
||||||
|
without CI secrets. In CI, the token is always set and the check is
|
||||||
|
mandatory.
|
||||||
|
|
||||||
|
If ``REPO_TOKEN`` is not set and ``--pr-number`` is not provided, only
|
||||||
|
branch-name and PR-title-format checks run (local mode).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
import click
|
||||||
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
|
||||||
|
from devx.api_clients import GiteaClient, VikunjaClient
|
||||||
|
from devx.ci.auto_merge import extract_task_id
|
||||||
|
from devx.config import (
|
||||||
|
GITEA_API_URL,
|
||||||
|
VIKUNJA_API_URL,
|
||||||
|
VIKUNJA_PROJECT_ID,
|
||||||
|
)
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def is_branch_behind_master(branch: str) -> bool:
|
||||||
|
"""Check if the local branch is behind origin/master.
|
||||||
|
|
||||||
|
Fetches origin first (best-effort) then compares commit counts.
|
||||||
|
Returns ``True`` if master has commits not in branch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
["git", "fetch", "origin", "master", "--quiet"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
["git", "rev-list", "--count", f"origin/master..{branch}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False # Can't determine — don't block
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
["git", "rev-list", "--count", f"{branch}..origin/master"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
behind = int(result.stdout.strip() or "0")
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
||||||
|
return False # Don't block on git errors
|
||||||
|
return behind > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||||
|
"""Fetch the PR title from the Gitea API.
|
||||||
|
|
||||||
|
Returns ``None`` if ``REPO_TOKEN`` is not set or the PR cannot be fetched.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("REPO_TOKEN", "")
|
||||||
|
if not token or "/" not in repo:
|
||||||
|
return None
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||||
|
try:
|
||||||
|
pr = client.get_pr(pr_number)
|
||||||
|
return str(pr.get("title", ""))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_vikunja_title_optional(task_id: str) -> str | None:
|
||||||
|
"""Fetch the Vikunja task title, returning None if token is not set.
|
||||||
|
|
||||||
|
Unlike :func:`devx.ci.auto_merge.get_vikunja_task_title`, this does NOT
|
||||||
|
raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the
|
||||||
|
caller can skip the check in local mode.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
|
from devx.config import DEFAULT_PER_PAGE
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||||
|
if not tasks:
|
||||||
|
break
|
||||||
|
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||||
|
if matches:
|
||||||
|
return str(matches[0].get("title", ""))
|
||||||
|
if len(tasks) < DEFAULT_PER_PAGE:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--branch", required=True, help=_("Branch name (e.g., DEVX-256-fix-foo)"))
|
||||||
|
@click.option("--pr-title", default=None, help=_("PR title (auto-fetched if --pr-number given)"))
|
||||||
|
@click.option("--repo", default=None, help=_("Repository in owner/name format"))
|
||||||
|
@click.option("--pr-number", type=int, default=None, help=_("PR number (to fetch title from Gitea)"))
|
||||||
|
@click.option("--skip-vikunja", is_flag=True, help=_("Skip Vikunja title match check"))
|
||||||
|
@click.option("--skip-behind-check", is_flag=True, help=_("Skip branch-behind-master check"))
|
||||||
|
def cli(
|
||||||
|
branch: str,
|
||||||
|
pr_title: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
pr_number: int | None,
|
||||||
|
skip_vikunja: bool,
|
||||||
|
skip_behind_check: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Validate auto-merge preconditions before expensive CI jobs."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from devx.config import TASK_PREFIX
|
||||||
|
|
||||||
|
pr_title_re = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") # noqa: PLW1503
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# 1. Branch task ID
|
||||||
|
task_id = extract_task_id(branch)
|
||||||
|
if not task_id:
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||||
|
branch=branch,
|
||||||
|
prefix=TASK_PREFIX,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Can't continue — no task ID to validate against
|
||||||
|
for e in errors:
|
||||||
|
click.echo(f"ERROR: {e}", err=True)
|
||||||
|
raise click.ClickException(_("Branch name must contain a task ID."))
|
||||||
|
|
||||||
|
click.echo(f"[pre-merge-check] Task ID: {task_id}")
|
||||||
|
|
||||||
|
# 2. Resolve PR title
|
||||||
|
if pr_title is None and pr_number is not None and repo is not None:
|
||||||
|
pr_title = get_pr_title_from_gitea(repo, pr_number)
|
||||||
|
if pr_title:
|
||||||
|
click.echo(f"[pre-merge-check] PR title (from Gitea): {pr_title}")
|
||||||
|
|
||||||
|
if pr_title is None:
|
||||||
|
# Local mode without PR — only validate branch name
|
||||||
|
if pr_number is not None:
|
||||||
|
raise click.ClickException(_("Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."))
|
||||||
|
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).")
|
||||||
|
click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. PR title format
|
||||||
|
if not pr_title_re.match(pr_title):
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||||
|
prefix=TASK_PREFIX,
|
||||||
|
title=pr_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. PR title task ID matches branch task ID
|
||||||
|
if not pr_title.startswith(f"{task_id}:"):
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||||
|
task_id=task_id,
|
||||||
|
title=pr_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Vikunja task title match (skip if no token or --skip-vikunja)
|
||||||
|
if not skip_vikunja:
|
||||||
|
vikunja_title = get_vikunja_title_optional(task_id)
|
||||||
|
if vikunja_title is None:
|
||||||
|
token_set = bool(os.environ.get("VIKUNJA_TOKEN", ""))
|
||||||
|
if token_set:
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||||
|
task_id=task_id,
|
||||||
|
project_id=VIKUNJA_PROJECT_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
|
||||||
|
else:
|
||||||
|
expected = f"{task_id}: {vikunja_title}"
|
||||||
|
if pr_title != expected:
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
expected=expected,
|
||||||
|
title=pr_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
|
||||||
|
|
||||||
|
# 6. Branch behind master (skip if --skip-behind-check)
|
||||||
|
if not skip_behind_check:
|
||||||
|
if is_branch_behind_master(branch):
|
||||||
|
errors.append(
|
||||||
|
_("Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo("[pre-merge-check] Branch is up-to-date with origin/master.")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo("=" * 60, err=True)
|
||||||
|
click.echo("Pre-merge validation FAILED — fix these before pushing:", err=True)
|
||||||
|
click.echo("=" * 60, err=True)
|
||||||
|
for e in errors:
|
||||||
|
click.echo(f" - {e}", err=True)
|
||||||
|
raise click.ClickException(_("Pre-merge validation failed."))
|
||||||
|
|
||||||
|
click.echo("[pre-merge-check] All auto-merge preconditions satisfied.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Distribute a list of files across N parallel runners (round-robin).
|
"""Distribute a list of files across N parallel runners using LPT scheduling.
|
||||||
|
|
||||||
Generic file-based test distribution for CI matrix jobs. Discovers files
|
Generic file-based test distribution for CI matrix jobs. Discovers files
|
||||||
matching a glob pattern, sorts them for deterministic ordering, then
|
matching a glob pattern, sorts them for deterministic ordering, then
|
||||||
assigns them round-robin to *max_runners* groups. The assigned group for
|
assigns them to *max_runners* groups using LPT (Longest Processing Time
|
||||||
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
|
first) scheduling — files are weighted by size (as a proxy for test
|
||||||
|
runtime) and assigned to the runner with the least total weight.
|
||||||
|
|
||||||
|
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` for
|
||||||
|
use by subsequent steps.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -32,11 +36,32 @@ def discover_files(pattern: str) -> list[str]:
|
|||||||
return sorted(glob.glob(pattern))
|
return sorted(glob.glob(pattern))
|
||||||
|
|
||||||
|
|
||||||
|
def _file_weight(path: str) -> int:
|
||||||
|
"""Estimate a weight for a file based on its size in bytes.
|
||||||
|
|
||||||
|
Falls back to 1 if the file cannot be stat'd (e.g. in tests).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return max(1, os.path.getsize(path))
|
||||||
|
except OSError:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
||||||
"""Split *files* into *max_runners* balanced groups (round-robin)."""
|
"""Split *files* into *max_runners* balanced groups using LPT scheduling.
|
||||||
|
|
||||||
|
Files are weighted by size (as a proxy for runtime) and assigned to
|
||||||
|
the runner with the least total weight.
|
||||||
|
"""
|
||||||
|
weights = [_file_weight(f) for f in files]
|
||||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||||
for i, f in enumerate(files):
|
loads = [0] * max_runners
|
||||||
groups[i % max_runners].append(f)
|
# Sort by weight descending, preserving original order for ties
|
||||||
|
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
|
||||||
|
for orig_idx, f in indexed:
|
||||||
|
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||||
|
groups[min_runner].append(f)
|
||||||
|
loads[min_runner] += weights[orig_idx]
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,21 @@ def run_tests() -> None:
|
|||||||
click.echo(_("Tests passed."))
|
click.echo(_("Tests passed."))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_github_output(tag: str) -> None:
|
||||||
|
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
|
||||||
|
|
||||||
|
This allows a publish job (needs: release) to read the tag via
|
||||||
|
``${{ needs.release.outputs.tag }}`` instead of relying on
|
||||||
|
tag-push event triggering a separate workflow.
|
||||||
|
"""
|
||||||
|
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||||
|
if not github_output:
|
||||||
|
return
|
||||||
|
with open(github_output, "a") as f: # noqa: PTH123
|
||||||
|
f.write(f"tag={tag}\n")
|
||||||
|
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
|
||||||
|
|
||||||
|
|
||||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
|
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.
|
"""Create an annotated tag with the changelog as message and push it.
|
||||||
|
|
||||||
@@ -345,6 +360,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
|||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Ensure the existing tag is pushed
|
# Ensure the existing tag is pushed
|
||||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||||
|
_write_github_output(tag)
|
||||||
return False
|
return False
|
||||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -352,6 +368,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
|||||||
return True
|
return True
|
||||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||||
|
_write_github_output(tag)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -617,6 +634,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
|||||||
tag=release_tag,
|
tag=release_tag,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_write_github_output(release_tag)
|
||||||
return
|
return
|
||||||
# Tag is missing — recover by creating and pushing it
|
# Tag is missing — recover by creating and pushing it
|
||||||
click.echo(
|
click.echo(
|
||||||
|
|||||||
+65
-7
@@ -1,28 +1,86 @@
|
|||||||
"""Shared configuration constants for devx scripts and API clients.
|
"""Shared configuration constants for devx scripts and API clients.
|
||||||
|
|
||||||
All defaults can be overridden via environment variables with the ``DEVX_``
|
Configuration is read from two sources, in priority order:
|
||||||
prefix. Projects consuming devx can set these in their ``.env`` files.
|
|
||||||
|
1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for
|
||||||
|
CI secrets and per-run overrides.
|
||||||
|
2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults,
|
||||||
|
read from the current working directory.
|
||||||
|
|
||||||
|
If neither source provides a value, built-in defaults are used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pyproject_devx() -> dict[str, object]:
|
||||||
|
"""Load the ``[tool.devx]`` section from pyproject.toml in the CWD.
|
||||||
|
|
||||||
|
Returns an empty dict if the file or section is missing.
|
||||||
|
"""
|
||||||
|
path = Path("pyproject.toml")
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
|
data: dict[str, object] = tomllib.load(f)
|
||||||
|
except (tomllib.TOMLDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
tool_raw: object = data.get("tool", {})
|
||||||
|
if not isinstance(tool_raw, dict):
|
||||||
|
return {}
|
||||||
|
tool: dict[str, object] = tool_raw # type: ignore[assignment]
|
||||||
|
devx_raw: object = tool.get("devx", {})
|
||||||
|
if not isinstance(devx_raw, dict):
|
||||||
|
return {}
|
||||||
|
devx: dict[str, object] = devx_raw # type: ignore[assignment]
|
||||||
|
return devx
|
||||||
|
|
||||||
|
|
||||||
|
_PYPROJECT = _load_pyproject_devx()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(key: str, env_var: str, default: str) -> str:
|
||||||
|
"""Get a config value: env var > pyproject.toml > default."""
|
||||||
|
env_val = os.getenv(env_var)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
|
pyproject_val = _PYPROJECT.get(key)
|
||||||
|
if isinstance(pyproject_val, str):
|
||||||
|
return pyproject_val
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(key: str, env_var: str, default: int) -> int:
|
||||||
|
"""Get an int config value: env var > pyproject.toml > default."""
|
||||||
|
env_val = os.getenv(env_var)
|
||||||
|
if env_val is not None:
|
||||||
|
return int(env_val)
|
||||||
|
pyproject_val = _PYPROJECT.get(key)
|
||||||
|
if isinstance(pyproject_val, int):
|
||||||
|
return pyproject_val
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
# 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")
|
GITEA_API_URL = _get("gitea_api_url", "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")
|
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||||
|
|
||||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||||
# No default: prevents silent 404s when the wrong owner is used.
|
# No default: prevents silent 404s when the wrong owner is used.
|
||||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
|
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||||
|
|
||||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||||
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
||||||
|
|
||||||
# Vikunja project ID — each project uses a different Vikunja project
|
# Vikunja project ID — each project uses a different Vikunja project
|
||||||
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
|
VIKUNJA_PROJECT_ID = _get_int("vikunja_project_id", "DEVX_VIKUNJA_PROJECT_ID", 6)
|
||||||
|
|
||||||
# HTTP client defaults
|
# HTTP client defaults
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|||||||
@@ -82,12 +82,15 @@ class TeaCLI:
|
|||||||
cmd = [self._tea, *args]
|
cmd = [self._tea, *args]
|
||||||
if json_output:
|
if json_output:
|
||||||
cmd.extend(["--output", "json"])
|
cmd.extend(["--output", "json"])
|
||||||
result = subprocess.run( # nosec B603
|
try:
|
||||||
cmd,
|
result = subprocess.run( # nosec B603
|
||||||
capture_output=True,
|
cmd,
|
||||||
text=True,
|
capture_output=True,
|
||||||
check=False,
|
text=True,
|
||||||
)
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise TeaCLIError(
|
raise TeaCLIError(
|
||||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# devx.mak — Shared Makefile fragment for devx-integrated projects.
|
||||||
|
#
|
||||||
|
# This fragment provides common targets for:
|
||||||
|
# - Vikunja task management and PR creation
|
||||||
|
# - Workflow validation (actionlint, act_runner)
|
||||||
|
# - Linting (ruff, pyright, bandit, pip-audit)
|
||||||
|
# - CI failure notification
|
||||||
|
# - Environment setup (venv, .env, hooks)
|
||||||
|
# - Test execution and quality checks
|
||||||
|
#
|
||||||
|
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
|
||||||
|
# is read from [tool.devx] in pyproject.toml by devx.config — no
|
||||||
|
# Makefile variables needed.
|
||||||
|
#
|
||||||
|
# Usage in your Makefile:
|
||||||
|
#
|
||||||
|
# # Set DEVX_PYTHON to your venv's Python
|
||||||
|
# DEVX_PYTHON := $(BIN)/python
|
||||||
|
#
|
||||||
|
# # Include the devx fragment (silent if devx not installed yet)
|
||||||
|
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
|
||||||
|
# "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||||
|
# 2>/dev/null)
|
||||||
|
# -include $(DEVX_MAK)
|
||||||
|
#
|
||||||
|
# If devx is not installed, the -include silently skips and the targets
|
||||||
|
# are simply unavailable (run 'make setup' first).
|
||||||
|
#
|
||||||
|
# Variables (set BEFORE including this fragment):
|
||||||
|
# DEVX_PYTHON — Python executable (default: python3)
|
||||||
|
# DEVX_PR_BASE — PR base branch (default: master)
|
||||||
|
# DEVX_VENV — venv directory name (default: .venv)
|
||||||
|
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
|
||||||
|
# DEVX_LINT_PATHS — paths for ruff/bandit (default: src/ tests/)
|
||||||
|
# DEVX_TYPECHECK_PATHS — paths for pyright (default: empty — uses pyright config)
|
||||||
|
# DEVX_COV_PKG — coverage package name (default: src/devx)
|
||||||
|
# DEVX_TEST_PATHS — pytest paths (default: tests/)
|
||||||
|
# DEVX_GITEA_PYPI_HOST — Gitea PyPI host (default: git.oblachno.oblachno.fyi)
|
||||||
|
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
|
||||||
|
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
|
||||||
|
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
|
||||||
|
|
||||||
|
DEVX_PYTHON ?= python3
|
||||||
|
DEVX_PR_BASE ?= master
|
||||||
|
DEVX_VENV ?= .venv
|
||||||
|
DEVX_BIN ?= $(DEVX_VENV)/bin
|
||||||
|
DEVX_LINT_PATHS ?= src/ tests/
|
||||||
|
DEVX_COV_PKG ?= src/devx
|
||||||
|
DEVX_TEST_PATHS ?= tests/
|
||||||
|
DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
|
||||||
|
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||||
|
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||||
|
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||||
|
|
||||||
|
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||||
|
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||||
|
# GITEA_PYPI_USER can be set in .env, as an env var, or as a Make variable.
|
||||||
|
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||||
|
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||||
|
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \
|
||||||
|
if [ -n "$$REPO_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$REPO_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||||
|
$(DEVX_BIN)/pip
|
||||||
|
|
||||||
|
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||||
|
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||||
|
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||||
|
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||||
|
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
|
||||||
|
.PHONY: devx-clean devx-pre-push
|
||||||
|
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
||||||
|
.PHONY: devx-test-unit devx-pytest-cov
|
||||||
|
|
||||||
|
# ── Vikunja task and PR management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
|
||||||
|
devx-create-task:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.create_task
|
||||||
|
|
||||||
|
# Create a PR with title auto-derived from the Vikunja task
|
||||||
|
# (owner/repo read from [tool.devx] in pyproject.toml)
|
||||||
|
devx-create-pr:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
|
||||||
|
|
||||||
|
# Push current branch to origin
|
||||||
|
devx-push:
|
||||||
|
@git push -u origin HEAD
|
||||||
|
|
||||||
|
# Validate devx configuration in pyproject.toml
|
||||||
|
devx-check-config:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_config
|
||||||
|
|
||||||
|
# Push and create PR in one step
|
||||||
|
devx-push-with-pr: devx-push devx-create-pr
|
||||||
|
|
||||||
|
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||||
|
# private packages. In CI, REPO_TOKEN is set as a secret. Locally, it's in .env.
|
||||||
|
devx-configure-gitea-pypi:
|
||||||
|
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||||
|
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||||
|
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||||
|
echo "[configure-gitea-pypi] Gitea PyPI registry configured (REPO_TOKEN present)."
|
||||||
|
|
||||||
|
# Create .env from .env.example if it doesn't exist
|
||||||
|
devx-env:
|
||||||
|
@if [ ! -f .env ]; then \
|
||||||
|
cp .env.example .env; \
|
||||||
|
echo "Created .env from .env.example — please edit it with your credentials."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create Python venv with version check
|
||||||
|
devx-venv:
|
||||||
|
@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')"
|
||||||
|
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
|
||||||
|
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# Create activate scripts for shell/fish/zsh
|
||||||
|
devx-activate-scripts:
|
||||||
|
@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)
|
||||||
|
|
||||||
|
# Set git hooks path to hooks/
|
||||||
|
devx-install-hooks:
|
||||||
|
@git config core.hooksPath hooks
|
||||||
|
@chmod +x hooks/pre-commit hooks/pre-push 2>/dev/null || true
|
||||||
|
@echo "core.hooksPath set to hooks/ — tracked hooks are now live."
|
||||||
|
|
||||||
|
# ── Tool installation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Install CI/CD tools (actionlint, git-cliff, act_runner, tea) to ~/.local/bin
|
||||||
|
devx-install-tools:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.install_tools
|
||||||
|
|
||||||
|
# Install checkmake (Makefile linter)
|
||||||
|
devx-install-checkmake:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.install_checkmake
|
||||||
|
|
||||||
|
# Lint Makefiles with checkmake
|
||||||
|
devx-checkmake:
|
||||||
|
@CHECKMAKE_EXE="$$(command -v checkmake 2>/dev/null || echo $(HOME)/.local/bin/checkmake)"; \
|
||||||
|
if ! command -v "$$CHECKMAKE_EXE" >/dev/null 2>&1 && ! [ -x "$$CHECKMAKE_EXE" ]; then \
|
||||||
|
echo "[checkmake] checkmake not found. Run: make devx-install-checkmake"; exit 1; \
|
||||||
|
fi; \
|
||||||
|
"$$CHECKMAKE_EXE" $(CURDIR)/Makefile
|
||||||
|
|
||||||
|
# ── Workflow validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Static lint of Gitea Actions workflow YAML files
|
||||||
|
devx-workflow-lint:
|
||||||
|
@command -v actionlint >/dev/null 2>&1 || { \
|
||||||
|
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
actionlint -config-file $(DEVX_ACTIONLINT_CFG) $(DEVX_WORKFLOW_DIR)/*.yml
|
||||||
|
|
||||||
|
# Dry-run all workflows (requires act_runner)
|
||||||
|
devx-workflow-dryrun:
|
||||||
|
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
|
||||||
|
@echo "Dry-running all workflows (no Docker containers started)..."
|
||||||
|
act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
|
||||||
|
|
||||||
|
# Best-effort dry-run (skips if act_runner is not installed)
|
||||||
|
devx-workflow-dryrun-safe:
|
||||||
|
@command -v act_runner >/dev/null 2>&1 && { echo "Dry-running workflows..."; act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'; } || echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
|
||||||
|
|
||||||
|
# Static lint + dry-run
|
||||||
|
devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
|
||||||
|
@echo "Workflow checks passed (static lint + dry-run)."
|
||||||
|
|
||||||
|
# ── CI failure notification ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
|
||||||
|
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
|
||||||
|
# Requires: REPO_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
|
||||||
|
devx-notify-failure:
|
||||||
|
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
|
||||||
|
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||||
|
$(DEVX_PYTHON) -m devx.tools.install_tools --tool tea 2>/dev/null || true; \
|
||||||
|
$(DEVX_PYTHON) -m devx.ci.notify_failure --auto-login \
|
||||||
|
--repo "$${GITHUB_REPOSITORY}" \
|
||||||
|
--run-id "$${GITHUB_RUN_ID}" \
|
||||||
|
--workflow "$(WORKFLOW)" \
|
||||||
|
--commit "$${GITHUB_SHA}"
|
||||||
|
|
||||||
|
# ── Linting ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
devx-lint-ruff:
|
||||||
|
@$(DEVX_BIN)/ruff check $(DEVX_LINT_PATHS)
|
||||||
|
|
||||||
|
devx-lint-format:
|
||||||
|
@$(DEVX_BIN)/ruff format --check $(DEVX_LINT_PATHS)
|
||||||
|
|
||||||
|
devx-typecheck:
|
||||||
|
@$(DEVX_BIN)/pyright
|
||||||
|
|
||||||
|
devx-lint-bandit:
|
||||||
|
@$(DEVX_BIN)/bandit -r src/
|
||||||
|
|
||||||
|
devx-lint-deps:
|
||||||
|
@echo "Checking dependencies for known vulnerabilities..."
|
||||||
|
@$(DEVX_BIN)/python -m ensurepip 2>/dev/null || true
|
||||||
|
@PIPAPI_PYTHON_LOCATION=$$(pwd)/$(DEVX_VENV)/bin/python \
|
||||||
|
$(DEVX_BIN)/pip-audit --desc --skip-editable 2>&1 || true
|
||||||
|
|
||||||
|
devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
|
||||||
|
@echo "[devx-lint] Linting checks passed."
|
||||||
|
|
||||||
|
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
devx-test-unit:
|
||||||
|
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
|
||||||
|
|
||||||
|
devx-pytest-cov:
|
||||||
|
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||||
|
|
||||||
|
# ── Quality checks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Scan for module-level mutable globals that cause test isolation bugs
|
||||||
|
devx-check-mutable-globals:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_mutable_globals
|
||||||
|
|
||||||
|
# Validate that every dependency in pyproject.toml has a documented purpose
|
||||||
|
devx-check-dep-docs:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_pyproject_deps
|
||||||
|
|
||||||
|
# Check that changed files have corresponding tests
|
||||||
|
devx-check-test-coverage:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_test_coverage
|
||||||
|
|
||||||
|
# Validate agent and user docs for stale file references
|
||||||
|
devx-check-docs:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_agent_docs
|
||||||
|
|
||||||
|
# Verify test suite timing
|
||||||
|
devx-check-test-speed:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
|
||||||
|
|
||||||
|
# ── Pre-push validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run lint + tests before push (projects can override with project-specific targets)
|
||||||
|
devx-pre-push: devx-lint devx-pytest-cov
|
||||||
|
@echo "[devx-pre-push] All checks passed. Proceeding with push."
|
||||||
|
|
||||||
|
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
devx-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/ .molecule/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Pre-built image setup ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
|
||||||
|
# ci-full), all deps are already installed in /opt/venv. This target links
|
||||||
|
# the venv and installs the project itself (no-deps, fast).
|
||||||
|
# Falls back to devx-setup-ci if /opt/venv is not present (local dev).
|
||||||
|
|
||||||
|
devx-setup-image:
|
||||||
|
@if [ -d /opt/venv ]; then \
|
||||||
|
ln -sf /opt/venv $(DEVX_VENV); \
|
||||||
|
. $(DEVX_BIN)/activate && pip install -e . --no-deps 2>/dev/null; \
|
||||||
|
echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \
|
||||||
|
else \
|
||||||
|
echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \
|
||||||
|
$(MAKE) devx-setup-ci; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Docker image build / push / cleanup ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Variables:
|
||||||
|
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
|
||||||
|
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
|
||||||
|
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
|
||||||
|
|
||||||
|
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
|
||||||
|
DEVX_IMAGE_MANIFEST ?= docker/images.json
|
||||||
|
DEVX_IMAGE_OWNER ?= oblachno-oss
|
||||||
|
|
||||||
|
# Build all images from manifest (no push)
|
||||||
|
devx-build-images:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull
|
||||||
|
|
||||||
|
# Build and push all images to the Gitea registry
|
||||||
|
devx-push-images:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.build_image \
|
||||||
|
--manifest $(DEVX_IMAGE_MANIFEST) \
|
||||||
|
--registry $(DEVX_GITEA_REGISTRY) \
|
||||||
|
--push --pull
|
||||||
|
|
||||||
|
# Dry-run: show what would be built/pushed
|
||||||
|
devx-build-images-dry-run:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.build_image \
|
||||||
|
--manifest $(DEVX_IMAGE_MANIFEST) \
|
||||||
|
--registry $(DEVX_GITEA_REGISTRY) \
|
||||||
|
--push --dry-run
|
||||||
|
|
||||||
|
# Clean up old image versions (keep last 2 + latest)
|
||||||
|
devx-clean-images:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.clean_images \
|
||||||
|
--owner $(DEVX_IMAGE_OWNER) \
|
||||||
|
--name oblachno-oss/runner-images/ci-base \
|
||||||
|
--name oblachno-oss/runner-images/ci-quality \
|
||||||
|
--name oblachno-oss/runner-images/ci-full \
|
||||||
|
--keep 2
|
||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -132,14 +133,118 @@ def build_multi_role_pairs(
|
|||||||
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
||||||
|
|
||||||
|
|
||||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
# --- Molecule weight configuration ---
|
||||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
#
|
||||||
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
|
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
|
||||||
for i, pair in enumerate(pairs):
|
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
|
||||||
groups[i % max_runners].append(pair)
|
# weights calibrated from actual CI execution times.
|
||||||
|
#
|
||||||
|
# Two key formats are supported:
|
||||||
|
# - ``"scenario" = weight`` — applies to any role with that scenario name
|
||||||
|
# - ``"role/scenario" = weight`` — role-specific (takes priority)
|
||||||
|
#
|
||||||
|
# Example pyproject.toml::
|
||||||
|
#
|
||||||
|
# [tool.devx.molecule.weights]
|
||||||
|
# "nextcloud" = 15
|
||||||
|
# "app_container/customer-apps" = 11
|
||||||
|
# "restore/default" = 11
|
||||||
|
# "default" = 3
|
||||||
|
#
|
||||||
|
# If no configuration is found, a generic default weight is used for all
|
||||||
|
# scenarios (producing a round-robin distribution).
|
||||||
|
|
||||||
|
_DEFAULT_SCENARIO_WEIGHT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _load_molecule_weights(pyproject_path: str = "pyproject.toml") -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
||||||
|
"""Load molecule weights from ``[tool.devx.molecule.weights]`` in pyproject.toml.
|
||||||
|
|
||||||
|
Returns a tuple of ``(scenario_weights, role_scenario_weights)``:
|
||||||
|
- ``scenario_weights``: maps scenario name → weight (applies to any role)
|
||||||
|
- ``role_scenario_weights``: maps (role, scenario) → weight (role-specific)
|
||||||
|
"""
|
||||||
|
path = Path(pyproject_path)
|
||||||
|
if not path.exists():
|
||||||
|
return {}, {}
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
|
data = tomllib.load(f)
|
||||||
|
except (tomllib.TOMLDecodeError, OSError):
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
weights_raw = data.get("tool", {}).get("devx", {}).get("molecule", {}).get("weights", {})
|
||||||
|
if not isinstance(weights_raw, dict):
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
scenario_weights: dict[str, int] = {}
|
||||||
|
role_scenario_weights: dict[tuple[str, str], int] = {}
|
||||||
|
|
||||||
|
for key, value in weights_raw.items():
|
||||||
|
if not isinstance(value, int):
|
||||||
|
continue
|
||||||
|
if "/" in key:
|
||||||
|
role, scenario = key.split("/", 1)
|
||||||
|
role_scenario_weights[(role.lower(), scenario.lower())] = value
|
||||||
|
else:
|
||||||
|
scenario_weights[key.lower()] = value
|
||||||
|
|
||||||
|
return scenario_weights, role_scenario_weights
|
||||||
|
|
||||||
|
|
||||||
|
# Load weights once at import time (like devx.config and classify_changes)
|
||||||
|
_SCENARIO_WEIGHTS, _ROLE_SCENARIO_WEIGHTS = _load_molecule_weights()
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_weight(scenario: str, role: str | None = None) -> int:
|
||||||
|
"""Estimate a weight for a scenario based on its name and optionally its role.
|
||||||
|
|
||||||
|
Role-specific weights (``"role/scenario"``) take priority over
|
||||||
|
scenario-name-only weights (``"scenario"``). Falls back to the
|
||||||
|
default weight if no configuration matches.
|
||||||
|
"""
|
||||||
|
s = scenario.lower()
|
||||||
|
if role is not None:
|
||||||
|
r = role.lower()
|
||||||
|
key = (r, s)
|
||||||
|
if key in _ROLE_SCENARIO_WEIGHTS:
|
||||||
|
return _ROLE_SCENARIO_WEIGHTS[key]
|
||||||
|
for key, weight in _SCENARIO_WEIGHTS.items():
|
||||||
|
if key in s:
|
||||||
|
return weight
|
||||||
|
return _DEFAULT_SCENARIO_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||||
|
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
|
||||||
|
|
||||||
|
Sorts items by weight (descending), then assigns each to the runner
|
||||||
|
with the least total weight. This produces a more balanced distribution
|
||||||
|
than naive round-robin when items have varying costs.
|
||||||
|
"""
|
||||||
|
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
||||||
|
loads = [0] * max_runners
|
||||||
|
# Sort by weight descending, preserving original order for ties
|
||||||
|
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||||
|
for orig_idx, item in indexed:
|
||||||
|
# Find the runner with the minimum load
|
||||||
|
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||||
|
groups[min_runner].append(item)
|
||||||
|
loads[min_runner] += weights[orig_idx]
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||||
|
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||||
|
|
||||||
|
Each pair is weighted by role+scenario heuristics (e.g. ``nextcloud`` is
|
||||||
|
heavier than ``simple-app``). Pairs are sorted by weight descending and
|
||||||
|
assigned to the runner with the least total weight.
|
||||||
|
"""
|
||||||
|
weights = [_scenario_weight(p.scenario, p.role) for p in pairs]
|
||||||
|
return _lpt_distribute(pairs, weights, max_runners)
|
||||||
|
|
||||||
|
|
||||||
def multi_role_pairs_for_runner(
|
def multi_role_pairs_for_runner(
|
||||||
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
|
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
|
||||||
) -> list[MultiRoleTestPair]:
|
) -> list[MultiRoleTestPair]:
|
||||||
@@ -153,11 +258,14 @@ def multi_role_pairs_for_runner(
|
|||||||
|
|
||||||
|
|
||||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
|
||||||
for i, pair in enumerate(pairs):
|
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
||||||
groups[i % max_runners].append(pair)
|
heavier than ``binary``). Pairs are sorted by weight descending and
|
||||||
return groups
|
assigned to the runner with the least total weight.
|
||||||
|
"""
|
||||||
|
weights = [_scenario_weight(p.scenario) for p in pairs]
|
||||||
|
return _lpt_distribute(pairs, weights, max_runners)
|
||||||
|
|
||||||
|
|
||||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build and push Docker images to a Gitea container registry.
|
||||||
|
|
||||||
|
Replaces raw ``docker build`` / ``docker push`` shell commands with a
|
||||||
|
tested Python tool. Supports:
|
||||||
|
|
||||||
|
- Building from any Dockerfile with a configurable context directory
|
||||||
|
- Tagging with multiple tags (e.g. ``latest`` + version)
|
||||||
|
- Optional push to a Gitea registry (with login)
|
||||||
|
- Dry-run mode (prints commands without executing)
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Build a single image
|
||||||
|
python3 -m devx.tools.build_image \\
|
||||||
|
--dockerfile docker/ci-base/Dockerfile \\
|
||||||
|
--tag ci-base:latest \\
|
||||||
|
--tag ci-base:0.19.3
|
||||||
|
|
||||||
|
# Build and push to registry
|
||||||
|
python3 -m devx.tools.build_image \\
|
||||||
|
--dockerfile docker/ci-base/Dockerfile \\
|
||||||
|
--tag ci-base:latest \\
|
||||||
|
--tag ci-base:0.19.3 \\
|
||||||
|
--registry git.oblachno.oblachno.fyi \\
|
||||||
|
--push
|
||||||
|
|
||||||
|
# Build multiple images (from a manifest file)
|
||||||
|
python3 -m devx.tools.build_image --manifest docker/images.json --push
|
||||||
|
|
||||||
|
The manifest file is a JSON list of dicts, each with:
|
||||||
|
- ``name``: image name (e.g. ``ci-base``)
|
||||||
|
- ``dockerfile``: path to Dockerfile (relative to repo root)
|
||||||
|
- ``context``: build context directory (optional, defaults to repo root)
|
||||||
|
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
|
||||||
|
|
||||||
|
Registry authentication uses ``REPO_TOKEN`` (or ``GITEA_REGISTRY_TOKEN``)
|
||||||
|
and ``REGISTRY_USERNAME`` (or ``GITEA_REGISTRY_USERNAME``) environment
|
||||||
|
variables, matching the existing CI workflow patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageSpec:
|
||||||
|
"""Specification for a single Docker image to build."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
dockerfile: str
|
||||||
|
context: str = "."
|
||||||
|
tags: list[str] = field(default_factory=lambda: ["latest"])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, object]) -> ImageSpec:
|
||||||
|
"""Create an ImageSpec from a dict (e.g. from a JSON manifest)."""
|
||||||
|
name = str(data.get("name", ""))
|
||||||
|
if not name:
|
||||||
|
raise ValueError(_("Image manifest entry missing 'name'"))
|
||||||
|
dockerfile = str(data.get("dockerfile", ""))
|
||||||
|
if not dockerfile:
|
||||||
|
raise ValueError(_("Image manifest entry missing 'dockerfile'"))
|
||||||
|
context = str(data.get("context", "."))
|
||||||
|
tags_raw = data.get("tags", ["latest"])
|
||||||
|
if not isinstance(tags_raw, list):
|
||||||
|
raise ValueError(_("Image 'tags' must be a list"))
|
||||||
|
tags = [str(t) for t in tags_raw] if tags_raw else ["latest"]
|
||||||
|
return cls(name=name, dockerfile=dockerfile, context=context, tags=tags)
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(path: str | Path) -> list[ImageSpec]:
|
||||||
|
"""Load a JSON manifest file describing images to build.
|
||||||
|
|
||||||
|
The file must contain a JSON list of dicts with at least ``name`` and
|
||||||
|
``dockerfile`` keys. ``context`` and ``tags`` are optional.
|
||||||
|
|
||||||
|
Returns a list of :class:`ImageSpec` instances.
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
raise click.ClickException(_("Manifest file not found: {path}", path=p))
|
||||||
|
with p.open() as f: # noqa: PTH123
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise click.ClickException(_("Manifest must be a JSON list"))
|
||||||
|
return [ImageSpec.from_dict(entry) for entry in data]
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_tag(registry: str | None, name: str, tag: str) -> str:
|
||||||
|
"""Build a full image tag, optionally prefixed with a registry.
|
||||||
|
|
||||||
|
>>> build_full_tag(None, "ci-base", "latest")
|
||||||
|
'ci-base:latest'
|
||||||
|
>>> build_full_tag("git.example.com", "ci-base", "0.1.0")
|
||||||
|
'git.example.com/ci-base:0.1.0'
|
||||||
|
"""
|
||||||
|
if registry:
|
||||||
|
return f"{registry}/{name}:{tag}"
|
||||||
|
return f"{name}:{tag}"
|
||||||
|
|
||||||
|
|
||||||
|
def registry_login(
|
||||||
|
registry: str,
|
||||||
|
username: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Log in to a Docker registry.
|
||||||
|
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
In dry-run mode, prints the command without executing.
|
||||||
|
"""
|
||||||
|
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
|
||||||
|
if dry_run:
|
||||||
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||||
|
return True
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
|
cmd,
|
||||||
|
input=token,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
click.echo(
|
||||||
|
_("Registry login failed: {error}", error=result.stderr.strip()),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
click.echo(f"Logged in to {registry}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_image(
|
||||||
|
spec: ImageSpec,
|
||||||
|
registry: str | None = None,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
pull: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Build a Docker image from a Dockerfile.
|
||||||
|
|
||||||
|
Tags the image with all specified tags, optionally prefixed with the
|
||||||
|
registry. Returns True on success, False on failure.
|
||||||
|
"""
|
||||||
|
if not Path(spec.dockerfile).is_file():
|
||||||
|
click.echo(
|
||||||
|
_("Dockerfile not found: {path}", path=spec.dockerfile),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
||||||
|
cmd = ["docker", "build"]
|
||||||
|
if pull:
|
||||||
|
cmd.append("--pull")
|
||||||
|
for ft in full_tags:
|
||||||
|
cmd.extend(["-t", ft])
|
||||||
|
cmd.extend(["-f", spec.dockerfile, spec.context])
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
|
cmd,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
click.echo(_("Build failed for {name}", name=spec.name), err=True)
|
||||||
|
return False
|
||||||
|
click.echo(f"Built {spec.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def push_image(
|
||||||
|
spec: ImageSpec,
|
||||||
|
registry: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Push all tags of a Docker image to the registry.
|
||||||
|
|
||||||
|
Returns True if all pushes succeed, False if any fail.
|
||||||
|
"""
|
||||||
|
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
||||||
|
all_ok = True
|
||||||
|
for ft in full_tags:
|
||||||
|
cmd = ["docker", "push", ft]
|
||||||
|
if dry_run:
|
||||||
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||||
|
continue
|
||||||
|
click.echo(f"Pushing {ft}...")
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
click.echo(
|
||||||
|
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
all_ok = False
|
||||||
|
else:
|
||||||
|
click.echo(f"Pushed {ft}")
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def _get_registry_creds() -> tuple[str, str]:
|
||||||
|
"""Get registry credentials from environment variables.
|
||||||
|
|
||||||
|
Supports both REPO_TOKEN/GITEA_REGISTRY_TOKEN and
|
||||||
|
REGISTRY_USERNAME/GITEA_REGISTRY_USERNAME patterns.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("REPO_TOKEN") or os.environ.get("GITEA_REGISTRY_TOKEN", "")
|
||||||
|
username = os.environ.get("REGISTRY_USERNAME") or os.environ.get("GITEA_REGISTRY_USERNAME", "")
|
||||||
|
return username, token
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--dockerfile",
|
||||||
|
"dockerfile",
|
||||||
|
default=None,
|
||||||
|
help="Path to Dockerfile (for single-image build).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--context",
|
||||||
|
"context",
|
||||||
|
default=".",
|
||||||
|
help="Build context directory (for single-image build).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--name",
|
||||||
|
"name",
|
||||||
|
default=None,
|
||||||
|
help="Image name (for single-image build).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--tag",
|
||||||
|
"tags",
|
||||||
|
multiple=True,
|
||||||
|
help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--manifest",
|
||||||
|
"manifest",
|
||||||
|
default=None,
|
||||||
|
help="Path to JSON manifest file listing images to build.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--registry",
|
||||||
|
"registry",
|
||||||
|
default=None,
|
||||||
|
help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--push",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Push images to the registry after building.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Print commands without executing.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--pull",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Pass --pull to docker build (always fetch latest base image).",
|
||||||
|
)
|
||||||
|
def main(
|
||||||
|
dockerfile: str | None,
|
||||||
|
context: str,
|
||||||
|
name: str | None,
|
||||||
|
tags: tuple[str, ...],
|
||||||
|
manifest: str | None,
|
||||||
|
registry: str | None,
|
||||||
|
push: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
pull: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Build and optionally push Docker images to a Gitea registry."""
|
||||||
|
if manifest:
|
||||||
|
specs = load_manifest(manifest)
|
||||||
|
elif dockerfile and name:
|
||||||
|
tag_list = list(tags) if tags else ["latest"]
|
||||||
|
specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)]
|
||||||
|
else:
|
||||||
|
raise click.ClickException(_("Provide --manifest or both --dockerfile and --name"))
|
||||||
|
|
||||||
|
if push:
|
||||||
|
if not registry:
|
||||||
|
raise click.ClickException(_("--push requires --registry"))
|
||||||
|
username, token = _get_registry_creds()
|
||||||
|
if not token or not username:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars")
|
||||||
|
)
|
||||||
|
if not registry_login(registry, username, token, dry_run=dry_run):
|
||||||
|
raise click.ClickException(_("Registry login failed"))
|
||||||
|
|
||||||
|
failed: list[str] = []
|
||||||
|
for spec in specs:
|
||||||
|
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
|
||||||
|
failed.append(spec.name)
|
||||||
|
continue
|
||||||
|
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
|
||||||
|
failed.append(spec.name)
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
raise click.ClickException(_("Failed images: {names}", names=", ".join(failed)))
|
||||||
|
click.echo(f"\nDone. {len(specs)} image(s) processed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main() # pragma: no cover
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate agent documentation and user docs for stale file references.
|
||||||
|
|
||||||
|
Scans documentation files (``.devin/``, ``docs/``, ``README.md``) for:
|
||||||
|
- References to files that no longer exist
|
||||||
|
- References to deleted files (configurable blocklist)
|
||||||
|
- References to deprecated patterns (configurable regex patterns)
|
||||||
|
|
||||||
|
Configuration (``[tool.devx.check_agent_docs]`` in pyproject.toml):
|
||||||
|
|
||||||
|
``scan_dirs`` — directories to scan for docs (default: ``[".devin", "docs"]``)
|
||||||
|
``scan_files`` — specific files to scan (default: ``["README.md", "README.rst"]``)
|
||||||
|
``scan_extensions`` — file extensions to scan (default: ``[".md", ".yml", ".yaml"]``)
|
||||||
|
``excluded_paths`` — paths to exclude from scanning (default: ``["docs/retrospectives"]``)
|
||||||
|
``deleted_files`` — list of file paths that should never be referenced
|
||||||
|
``deprecated_patterns`` — list of regex patterns for deprecated references
|
||||||
|
``legitimate_indicators`` — substrings that indicate a legitimate reference to a deprecated pattern
|
||||||
|
``repo_path_prefixes`` — path prefixes that indicate a repo-relative reference
|
||||||
|
(default: ``["ansible/", "scripts/", "tofu/", ".devin/", "src/"]``)
|
||||||
|
``min_path_ref_length`` — minimum length for a path reference to be checked (default: 5)
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_agent_docs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.config import _load_pyproject_devx
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
MIN_PATH_REF_LENGTH_DEFAULT = 5
|
||||||
|
|
||||||
|
# Pattern that matches file path references in markdown or code
|
||||||
|
FILE_REF_RE = re.compile(
|
||||||
|
r"(?:`|\")?"
|
||||||
|
r"([\w\-./]+(?:\.[a-zA-Z0-9]+))"
|
||||||
|
r"(?:`|\))?"
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_SCAN_DIRS = [".devin", "docs"]
|
||||||
|
DEFAULT_SCAN_FILES = ["README.md", "README.rst"]
|
||||||
|
DEFAULT_SCAN_EXTENSIONS = [".md", ".yml", ".yaml"]
|
||||||
|
DEFAULT_EXCLUDED_PATHS = ["docs/retrospectives"]
|
||||||
|
DEFAULT_REPO_PATH_PREFIXES = ["ansible/", "scripts/", "tofu/", ".devin/", "src/"]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict[str, object]:
|
||||||
|
"""Load check_agent_docs configuration from pyproject.toml."""
|
||||||
|
devx_cfg = _load_pyproject_devx()
|
||||||
|
cfg_raw = devx_cfg.get("check_agent_docs", {})
|
||||||
|
if not isinstance(cfg_raw, dict):
|
||||||
|
return {}
|
||||||
|
return cfg_raw # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_skip(path: Path, excluded_paths: list[str], repo_root: Path) -> bool:
|
||||||
|
"""Check if a path should be excluded from scanning."""
|
||||||
|
try:
|
||||||
|
rel = str(path.relative_to(repo_root))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(excluded in rel for excluded in excluded_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_legitimate_ref(line: str, legitimate_indicators: list[str]) -> bool:
|
||||||
|
"""Check if a line contains a legitimate reference to a deprecated pattern."""
|
||||||
|
line_lower = line.lower()
|
||||||
|
return any(legit.lower() in line_lower for legit in legitimate_indicators)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_doc_files(
|
||||||
|
repo_root: Path,
|
||||||
|
scan_dirs: list[str],
|
||||||
|
scan_files: list[str],
|
||||||
|
scan_extensions: list[str],
|
||||||
|
excluded_paths: list[str],
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Collect all documentation files to scan."""
|
||||||
|
files: list[Path] = []
|
||||||
|
|
||||||
|
for scan_dir_name in scan_dirs:
|
||||||
|
scan_dir = repo_root / scan_dir_name
|
||||||
|
if not scan_dir.exists():
|
||||||
|
continue
|
||||||
|
for ext in scan_extensions:
|
||||||
|
for path in scan_dir.glob(f"**/*{ext}"):
|
||||||
|
if not _should_skip(path, excluded_paths, repo_root):
|
||||||
|
files.append(path)
|
||||||
|
|
||||||
|
for readme_name in scan_files:
|
||||||
|
path = repo_root / readme_name
|
||||||
|
if path.exists() and not _should_skip(path, excluded_paths, repo_root):
|
||||||
|
files.append(path)
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[Path] = set()
|
||||||
|
unique: list[Path] = []
|
||||||
|
for f in files:
|
||||||
|
if f not in seen:
|
||||||
|
seen.add(f)
|
||||||
|
unique.append(f)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _check_file(
|
||||||
|
path: Path,
|
||||||
|
repo_root: Path,
|
||||||
|
deleted_files: set[str],
|
||||||
|
deprecated_patterns: list[re.Pattern[str]],
|
||||||
|
legitimate_indicators: list[str],
|
||||||
|
repo_path_prefixes: list[str],
|
||||||
|
min_path_ref_length: int,
|
||||||
|
skip_ref_prefixes: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Check a single file for stale references."""
|
||||||
|
issues: list[str] = []
|
||||||
|
rel_path = path.relative_to(repo_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return issues
|
||||||
|
|
||||||
|
for lineno, line in enumerate(content.splitlines(), start=1):
|
||||||
|
# Check for deleted file references
|
||||||
|
for deleted in deleted_files:
|
||||||
|
if deleted in line:
|
||||||
|
issues.append(f"{rel_path}:{lineno}: references deleted file '{deleted}'")
|
||||||
|
|
||||||
|
# Check for deprecated pattern references
|
||||||
|
for pattern in deprecated_patterns:
|
||||||
|
if pattern.search(line) and not _is_legitimate_ref(line, legitimate_indicators):
|
||||||
|
issues.append(f"{rel_path}:{lineno}: matches deprecated pattern '{pattern.pattern}'")
|
||||||
|
|
||||||
|
# Check for references to files that don't exist
|
||||||
|
for match in FILE_REF_RE.finditer(line):
|
||||||
|
ref = match.group(1)
|
||||||
|
# Skip URLs, bare words, and short strings
|
||||||
|
if "/" not in ref or len(ref) < min_path_ref_length:
|
||||||
|
continue
|
||||||
|
# Only check references that look like repo paths
|
||||||
|
if not any(ref.startswith(prefix) for prefix in repo_path_prefixes):
|
||||||
|
continue
|
||||||
|
# Skip references matching configured skip prefixes (e.g. aspirational test files)
|
||||||
|
if any(ref.startswith(prefix) for prefix in skip_ref_prefixes):
|
||||||
|
continue
|
||||||
|
candidate = repo_root / ref
|
||||||
|
if not candidate.exists():
|
||||||
|
issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def cli() -> None:
|
||||||
|
"""Validate agent documentation and user docs for stale file references."""
|
||||||
|
repo_root = Path.cwd()
|
||||||
|
cfg = _load_config()
|
||||||
|
|
||||||
|
scan_dirs_raw = cfg.get("scan_dirs")
|
||||||
|
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
|
||||||
|
scan_files_raw = cfg.get("scan_files")
|
||||||
|
scan_files: list[str] = [str(d) for d in scan_files_raw] if isinstance(scan_files_raw, list) else DEFAULT_SCAN_FILES
|
||||||
|
scan_ext_raw = cfg.get("scan_extensions")
|
||||||
|
scan_extensions: list[str] = (
|
||||||
|
[str(d) for d in scan_ext_raw] if isinstance(scan_ext_raw, list) else DEFAULT_SCAN_EXTENSIONS
|
||||||
|
)
|
||||||
|
excluded_raw = cfg.get("excluded_paths")
|
||||||
|
excluded_paths: list[str] = (
|
||||||
|
[str(d) for d in excluded_raw] if isinstance(excluded_raw, list) else DEFAULT_EXCLUDED_PATHS
|
||||||
|
)
|
||||||
|
prefixes_raw = cfg.get("repo_path_prefixes")
|
||||||
|
repo_path_prefixes: list[str] = (
|
||||||
|
[str(d) for d in prefixes_raw] if isinstance(prefixes_raw, list) else DEFAULT_REPO_PATH_PREFIXES
|
||||||
|
)
|
||||||
|
min_len_raw = cfg.get("min_path_ref_length")
|
||||||
|
min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT
|
||||||
|
|
||||||
|
skip_prefixes_raw = cfg.get("skip_ref_prefixes", [])
|
||||||
|
skip_ref_prefixes: list[str] = [str(d) for d in skip_prefixes_raw] if isinstance(skip_prefixes_raw, list) else []
|
||||||
|
|
||||||
|
deleted_files: set[str] = set()
|
||||||
|
deleted_raw = cfg.get("deleted_files", [])
|
||||||
|
if isinstance(deleted_raw, list):
|
||||||
|
deleted_files = {str(d) for d in deleted_raw}
|
||||||
|
|
||||||
|
deprecated_patterns: list[re.Pattern[str]] = []
|
||||||
|
deprecated_raw = cfg.get("deprecated_patterns", [])
|
||||||
|
if isinstance(deprecated_raw, list):
|
||||||
|
for pattern_str in deprecated_raw:
|
||||||
|
if isinstance(pattern_str, str):
|
||||||
|
with contextlib.suppress(re.error):
|
||||||
|
deprecated_patterns.append(re.compile(pattern_str))
|
||||||
|
|
||||||
|
legitimate_indicators: list[str] = []
|
||||||
|
legit_raw = cfg.get("legitimate_indicators", [])
|
||||||
|
if isinstance(legit_raw, list):
|
||||||
|
legitimate_indicators = [str(s) for s in legit_raw]
|
||||||
|
|
||||||
|
files = _collect_doc_files(repo_root, scan_dirs, scan_files, scan_extensions, excluded_paths)
|
||||||
|
all_issues: list[str] = []
|
||||||
|
|
||||||
|
for path in sorted(files):
|
||||||
|
issues = _check_file(
|
||||||
|
path,
|
||||||
|
repo_root,
|
||||||
|
deleted_files,
|
||||||
|
deprecated_patterns,
|
||||||
|
legitimate_indicators,
|
||||||
|
repo_path_prefixes,
|
||||||
|
min_path_ref_length,
|
||||||
|
skip_ref_prefixes,
|
||||||
|
)
|
||||||
|
all_issues.extend(issues)
|
||||||
|
|
||||||
|
if all_issues:
|
||||||
|
click.echo(f"[check_agent_docs] Found {len(all_issues)} issue(s):\n", err=True)
|
||||||
|
for issue in all_issues:
|
||||||
|
click.echo(issue, err=True)
|
||||||
|
click.echo(
|
||||||
|
f"\n[check_agent_docs] FAILED: {len(all_issues)} stale reference(s)",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
raise click.ClickException(_("Found {count} stale documentation reference(s)", count=len(all_issues)))
|
||||||
|
|
||||||
|
click.echo(_("[check_agent_docs] Passed: scanned {count} file(s), no stale references", count=len(files)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate devx configuration consistency in pyproject.toml.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name)
|
||||||
|
2. devx version is consistent across all extras that mention it
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def cli() -> None:
|
||||||
|
"""Validate devx configuration in pyproject.toml."""
|
||||||
|
path = Path("pyproject.toml")
|
||||||
|
if not path.exists():
|
||||||
|
click.echo(_("pyproject.toml not found in current directory."))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Check [tool.devx] section
|
||||||
|
devx_cfg = data.get("tool", {}).get("devx", {})
|
||||||
|
required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"}
|
||||||
|
missing = required_keys - set(devx_cfg.keys())
|
||||||
|
if missing:
|
||||||
|
errors.append(
|
||||||
|
_("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check devx version consistency across extras
|
||||||
|
optional_deps = data.get("project", {}).get("optional-dependencies", {})
|
||||||
|
devx_versions: dict[str, str] = {}
|
||||||
|
for extra_name, deps in optional_deps.items():
|
||||||
|
for dep in deps:
|
||||||
|
# Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc.
|
||||||
|
m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep)
|
||||||
|
if m:
|
||||||
|
devx_versions[extra_name] = m.group(2)
|
||||||
|
|
||||||
|
if devx_versions:
|
||||||
|
unique_versions = set(devx_versions.values())
|
||||||
|
if len(unique_versions) > 1:
|
||||||
|
detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items()))
|
||||||
|
errors.append(
|
||||||
|
_("devx version mismatch across extras: {detail}", detail=detail),
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
click.echo(f"ERROR: {err}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Detect module-level mutable globals that may cause test isolation bugs.
|
||||||
|
|
||||||
|
Scans Python files for patterns like::
|
||||||
|
|
||||||
|
_SEEN: set[Path] = set()
|
||||||
|
_CACHE: dict[Path, Any] = {}
|
||||||
|
PATHS: list[Path] = []
|
||||||
|
|
||||||
|
These are hazardous because one test mutates the container and the next
|
||||||
|
sees stale state. The script reports the file/line and suggests a factory
|
||||||
|
function or fixture replacement.
|
||||||
|
|
||||||
|
Configuration (``[tool.devx.check_mutable_globals]`` in pyproject.toml):
|
||||||
|
|
||||||
|
``scan_dirs`` — list of directories to scan (default: ``["scripts", "tests"]``)
|
||||||
|
``skip_dirs`` — directory names to skip (default: ``__pycache__``, ``.pytest_cache``, ``venv``, ``.venv``)
|
||||||
|
``known_safe`` — list of ``"path:line:var_name"`` entries to ignore
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_mutable_globals
|
||||||
|
python3 -m devx.tools.check_mutable_globals --scan-dir src --scan-dir tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import contextlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.config import _load_pyproject_devx
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
MUTABLE_TYPES = {"set", "dict", "list"}
|
||||||
|
PATH_HINTS = ("path", "paths", "seen", "cache", "memo", "registry")
|
||||||
|
DEFAULT_SCAN_DIRS = ["scripts", "tests"]
|
||||||
|
DEFAULT_SKIP_DIRS = {"__pycache__", ".pytest_cache", "venv", ".venv"}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> tuple[list[str], set[str], set[tuple[str, int, str]]]:
|
||||||
|
"""Load configuration from pyproject.toml [tool.devx.check_mutable_globals]."""
|
||||||
|
devx_cfg = _load_pyproject_devx()
|
||||||
|
cfg_raw = devx_cfg.get("check_mutable_globals", {})
|
||||||
|
if not isinstance(cfg_raw, dict):
|
||||||
|
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_DIRS, set()
|
||||||
|
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||||
|
|
||||||
|
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
|
||||||
|
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
|
||||||
|
|
||||||
|
skip_dirs_raw = cfg.get("skip_dirs", list(DEFAULT_SKIP_DIRS))
|
||||||
|
skip_dirs: set[str] = {str(d) for d in skip_dirs_raw} if isinstance(skip_dirs_raw, list) else DEFAULT_SKIP_DIRS
|
||||||
|
|
||||||
|
known_safe_raw = cfg.get("known_safe", [])
|
||||||
|
known_safe: set[tuple[str, int, str]] = set()
|
||||||
|
if isinstance(known_safe_raw, list):
|
||||||
|
for entry in known_safe_raw:
|
||||||
|
if isinstance(entry, str) and entry.count(":") >= 2:
|
||||||
|
parts = entry.rsplit(":", 2)
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
known_safe.add((parts[0], int(parts[1]), parts[2]))
|
||||||
|
|
||||||
|
return scan_dirs, skip_dirs, known_safe
|
||||||
|
|
||||||
|
|
||||||
|
def _should_skip(path: Path, skip_dirs: set[str]) -> bool:
|
||||||
|
return any(part in skip_dirs for part in path.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def find_mutable_globals(
|
||||||
|
file_path: Path,
|
||||||
|
repo_root: Path,
|
||||||
|
known_safe: set[tuple[str, int, str]],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return a list of issue strings for mutable globals in *file_path*."""
|
||||||
|
issues: list[str] = []
|
||||||
|
try:
|
||||||
|
source = file_path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return issues
|
||||||
|
|
||||||
|
for node in ast.iter_child_nodes(tree):
|
||||||
|
if not isinstance(node, ast.AnnAssign | ast.Assign):
|
||||||
|
continue
|
||||||
|
|
||||||
|
names: list[str] = []
|
||||||
|
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||||
|
names.append(node.target.id)
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
names.append(target.id)
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
name_lower = name.lower()
|
||||||
|
value = node.value
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_mutable_literal = False
|
||||||
|
if isinstance(value, ast.Call):
|
||||||
|
if isinstance(value.func, ast.Name):
|
||||||
|
if value.func.id in MUTABLE_TYPES:
|
||||||
|
is_mutable_literal = True
|
||||||
|
elif isinstance(value.func, ast.Attribute):
|
||||||
|
# e.g. collections.defaultdict
|
||||||
|
pass
|
||||||
|
elif isinstance(value, (ast.Dict, ast.List, ast.Set)):
|
||||||
|
is_mutable_literal = True
|
||||||
|
|
||||||
|
if not is_mutable_literal:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if the name or type hint suggests Path usage
|
||||||
|
has_path_hint = any(hint in name_lower for hint in PATH_HINTS)
|
||||||
|
has_path_type = False
|
||||||
|
if isinstance(node, ast.AnnAssign) and node.annotation:
|
||||||
|
ann = ast.unparse(node.annotation)
|
||||||
|
has_path_type = "Path" in ann
|
||||||
|
|
||||||
|
if has_path_hint or has_path_type:
|
||||||
|
rel = str(file_path.relative_to(repo_root))
|
||||||
|
if (rel, node.lineno, name) in known_safe:
|
||||||
|
continue
|
||||||
|
value_str = ast.unparse(value) if value is not None else "..."
|
||||||
|
issues.append(
|
||||||
|
f"{rel}:{node.lineno}: mutable global {name!r} "
|
||||||
|
f"({value_str}) — use a factory function or pytest fixture"
|
||||||
|
)
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--scan-dir",
|
||||||
|
multiple=True,
|
||||||
|
help=_("Additional directory to scan (default: scripts, tests). Can be repeated."),
|
||||||
|
)
|
||||||
|
def cli(scan_dir: tuple[str, ...]) -> None:
|
||||||
|
"""Scan for module-level mutable globals that cause test isolation bugs."""
|
||||||
|
repo_root = Path.cwd()
|
||||||
|
config_scan_dirs, skip_dirs, known_safe = _load_config()
|
||||||
|
|
||||||
|
# CLI --scan-dir overrides config if provided
|
||||||
|
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
|
||||||
|
|
||||||
|
all_issues: list[str] = []
|
||||||
|
|
||||||
|
for scan_dir_name in scan_dirs:
|
||||||
|
scan_path = repo_root / scan_dir_name
|
||||||
|
if not scan_path.exists():
|
||||||
|
continue
|
||||||
|
for py_file in scan_path.rglob("*.py"):
|
||||||
|
if _should_skip(py_file, skip_dirs):
|
||||||
|
continue
|
||||||
|
all_issues.extend(find_mutable_globals(py_file, repo_root, known_safe))
|
||||||
|
|
||||||
|
if all_issues:
|
||||||
|
click.echo(f"[check-mutable-globals] FAILED: {len(all_issues)} issue(s)", err=True)
|
||||||
|
for issue in all_issues:
|
||||||
|
click.echo(f" {issue}", err=True)
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Found {count} mutable global(s) — use factory functions or pytest fixtures.", count=len(all_issues))
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(_("[check-mutable-globals] Passed: no mutable path globals found"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate that every dependency in pyproject.toml has a documented purpose.
|
||||||
|
|
||||||
|
This script does NOT resolve versions or query PyPI. It only ensures that
|
||||||
|
every dependency listed in ``[project.dependencies]`` or
|
||||||
|
``[project.optional-dependencies]`` has a corresponding comment nearby
|
||||||
|
explaining why it is needed.
|
||||||
|
|
||||||
|
Failure means a dependency lacks documentation.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_pyproject_deps
|
||||||
|
python3 -m devx.tools.check_pyproject_deps --file path/to/pyproject.toml
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
def check_deps(pyproject_path: Path) -> list[str]:
|
||||||
|
"""Return a list of issue strings for undocumented dependencies.
|
||||||
|
|
||||||
|
An empty list means all dependencies are documented.
|
||||||
|
"""
|
||||||
|
if not pyproject_path.exists():
|
||||||
|
return [str(pyproject_path) + ": file not found"]
|
||||||
|
|
||||||
|
content = pyproject_path.read_text(encoding="utf-8")
|
||||||
|
lines = content.splitlines()
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
in_deps_section = False
|
||||||
|
prev_was_comment = False
|
||||||
|
|
||||||
|
for i, raw_line in enumerate(lines, start=1):
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
|
||||||
|
# Detect section headers
|
||||||
|
if stripped in ("[project.dependencies]", "[project.optional-dependencies]"):
|
||||||
|
in_deps_section = True
|
||||||
|
continue
|
||||||
|
if stripped.startswith("[") and in_deps_section:
|
||||||
|
in_deps_section = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not in_deps_section:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# We're inside a dependency list
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
prev_was_comment = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("-") or stripped.startswith('"'):
|
||||||
|
if not prev_was_comment:
|
||||||
|
issues.append(f"{pyproject_path.name}:{i}: dependency lacks description comment: {stripped}")
|
||||||
|
prev_was_comment = False
|
||||||
|
else:
|
||||||
|
prev_was_comment = False
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--file",
|
||||||
|
"pyproject_file",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
default=Path("pyproject.toml"),
|
||||||
|
help=_("Path to pyproject.toml (default: pyproject.toml in CWD)."),
|
||||||
|
)
|
||||||
|
def cli(pyproject_file: Path) -> None:
|
||||||
|
"""Validate that every dependency in pyproject.toml has a documented purpose."""
|
||||||
|
issues = check_deps(pyproject_file)
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
click.echo(
|
||||||
|
_("FAILED: {count} undocumented dependency/ies", count=len(issues)),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
for issue in issues:
|
||||||
|
click.echo(f" {issue}", err=True)
|
||||||
|
raise click.ClickException(_("Dependencies must have documentation comments."))
|
||||||
|
|
||||||
|
click.echo(_("[check-dep-docs] Passed: all dependencies are documented"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pre-commit / CI check: ensure every changed or new file has corresponding tests.
|
||||||
|
|
||||||
|
Configuration (``[tool.devx.check_test_coverage]`` in pyproject.toml):
|
||||||
|
|
||||||
|
``rules`` — list of mapping rules, each with:
|
||||||
|
|
||||||
|
``source_pattern`` — glob pattern for source files (e.g. ``"scripts/*.py"``)
|
||||||
|
``test_paths`` — list of test path templates (e.g. ``["scripts/tests/test_{name}", "tests/unit/test_{name}"]``)
|
||||||
|
``description`` — human-readable description for error messages
|
||||||
|
|
||||||
|
``skip_patterns`` — list of file patterns to skip (e.g. ``["__init__.py", "config.py"]``)
|
||||||
|
``test_file_indicators`` — substrings that identify a file as a test (default: ``["tests/", "/test_", "_test.py"]``)
|
||||||
|
``skip_extensions`` — file extensions to skip (default: .md, .yml, .yaml, .json, .tf, .sh, .conf, .service)
|
||||||
|
|
||||||
|
Built-in defaults cover common Python project layouts (``scripts/*.py``, ``src/**/*.py``).
|
||||||
|
Project-specific rules are merged with defaults (first match wins).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_test_coverage [--staged-only] [--warn-only]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fnmatch
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from devx.config import _load_pyproject_devx
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
DEFAULT_TEST_INDICATORS = ["tests/", "/test_", "_test.py"]
|
||||||
|
DEFAULT_SKIP_EXTENSIONS = (".md", ".yml", ".yaml", ".json", ".tf", ".sh", ".conf", ".service")
|
||||||
|
|
||||||
|
# Built-in rules for common Python project layouts
|
||||||
|
BUILTIN_RULES: list[dict[str, object]] = [
|
||||||
|
{
|
||||||
|
"source_pattern": "scripts/*.py",
|
||||||
|
"test_paths": ["scripts/tests/test_{name}", "tests/unit/test_{name}"],
|
||||||
|
"description": "Missing unit test: scripts/tests/test_{name} or tests/unit/test_{name}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_pattern": "src/**/*.py",
|
||||||
|
"test_paths": ["tests/unit/test_{name}", "tests/unit/test_{module}_{name}"],
|
||||||
|
"description": "Missing unit test: tests/unit/test_{name}",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rules() -> tuple[list[dict[str, object]], list[str], list[str], tuple[str, ...]]:
|
||||||
|
"""Load test coverage rules from pyproject.toml."""
|
||||||
|
devx_cfg = _load_pyproject_devx()
|
||||||
|
cfg_raw = devx_cfg.get("check_test_coverage", {})
|
||||||
|
if not isinstance(cfg_raw, dict):
|
||||||
|
return BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS
|
||||||
|
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||||
|
|
||||||
|
rules_raw = cfg.get("rules", BUILTIN_RULES)
|
||||||
|
rules: list[dict[str, object]] = [dict(r) for r in rules_raw] if isinstance(rules_raw, list) else BUILTIN_RULES
|
||||||
|
|
||||||
|
skip_raw = cfg.get("skip_patterns", [])
|
||||||
|
skip_patterns: list[str] = [str(s) for s in skip_raw] if isinstance(skip_raw, list) else []
|
||||||
|
|
||||||
|
indicators_raw = cfg.get("test_file_indicators", DEFAULT_TEST_INDICATORS)
|
||||||
|
indicators: list[str] = (
|
||||||
|
[str(s) for s in indicators_raw] if isinstance(indicators_raw, list) else DEFAULT_TEST_INDICATORS
|
||||||
|
)
|
||||||
|
|
||||||
|
skip_ext_raw = cfg.get("skip_extensions", list(DEFAULT_SKIP_EXTENSIONS))
|
||||||
|
if isinstance(skip_ext_raw, list):
|
||||||
|
skip_ext: tuple[str, ...] = tuple(str(s) for s in skip_ext_raw)
|
||||||
|
else:
|
||||||
|
skip_ext = DEFAULT_SKIP_EXTENSIONS
|
||||||
|
|
||||||
|
return rules, skip_patterns, indicators, skip_ext
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_files(staged_only: bool, repo_root: Path) -> list[str]:
|
||||||
|
"""Return list of changed file paths relative to repo root."""
|
||||||
|
if staged_only:
|
||||||
|
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"]
|
||||||
|
else:
|
||||||
|
# Compare against origin/master for CI usage
|
||||||
|
cmd = ["git", "diff", "origin/master...HEAD", "--name-only", "--diff-filter=ACMR"]
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
cmd, capture_output=True, text=True, check=False, cwd=repo_root
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
# fallback: just use staged files
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_test_file(filepath: str, indicators: list[str]) -> bool:
|
||||||
|
"""Check if a file is a test file."""
|
||||||
|
return any(indicator in filepath for indicator in indicators)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_skip_file(
|
||||||
|
filepath: str,
|
||||||
|
skip_patterns: list[str],
|
||||||
|
skip_extensions: tuple[str, ...],
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a file should be skipped."""
|
||||||
|
if filepath.startswith("."):
|
||||||
|
return True
|
||||||
|
if filepath.endswith(skip_extensions):
|
||||||
|
return True
|
||||||
|
name = Path(filepath).name
|
||||||
|
return any(fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(filepath, pattern) for pattern in skip_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_test_path(template: str, source_path: str, repo_root: Path) -> Path:
|
||||||
|
"""Resolve a test path template to an actual path.
|
||||||
|
|
||||||
|
Templates can use:
|
||||||
|
- ``{name}`` — the source file's name (without extension)
|
||||||
|
- ``{module}`` — the source file's parent directory name
|
||||||
|
- ``{package_prefix}`` — underscore-joined subdirectories (for nested modules)
|
||||||
|
"""
|
||||||
|
path = Path(source_path)
|
||||||
|
name = path.stem
|
||||||
|
module = path.parent.name
|
||||||
|
|
||||||
|
# Build package prefix for nested modules (e.g. scripts/utils/secrets.py -> utils)
|
||||||
|
parts = path.parts
|
||||||
|
package_prefix = ""
|
||||||
|
if len(parts) > 2:
|
||||||
|
package_prefix = "_".join(parts[1:-1])
|
||||||
|
|
||||||
|
resolved = template.format(
|
||||||
|
name=name,
|
||||||
|
module=module,
|
||||||
|
package_prefix=package_prefix,
|
||||||
|
)
|
||||||
|
# Normalize hyphens to underscores (Python module naming)
|
||||||
|
resolved = resolved.replace("-", "_")
|
||||||
|
return repo_root / resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _find_missing_tests(
|
||||||
|
files: list[str],
|
||||||
|
repo_root: Path,
|
||||||
|
rules: list[dict[str, object]],
|
||||||
|
skip_patterns: list[str],
|
||||||
|
test_indicators: list[str],
|
||||||
|
skip_extensions: tuple[str, ...],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Map each untested file to the reason it's untested."""
|
||||||
|
missing: dict[str, str] = {}
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
# Skip test files themselves
|
||||||
|
if _is_test_file(f, test_indicators):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip config, docs, meta files
|
||||||
|
if _should_skip_file(f, skip_patterns, skip_extensions):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
pattern = str(rule.get("source_pattern", ""))
|
||||||
|
if not fnmatch.fnmatch(f, pattern):
|
||||||
|
continue
|
||||||
|
|
||||||
|
test_templates = rule.get("test_paths", [])
|
||||||
|
if not isinstance(test_templates, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
description_template = str(rule.get("description", "Missing test for {f}"))
|
||||||
|
|
||||||
|
test_paths = [_resolve_test_path(str(t), f, repo_root) for t in test_templates]
|
||||||
|
|
||||||
|
# Check if any test path exists (with .py extension)
|
||||||
|
found = False
|
||||||
|
for tp in test_paths:
|
||||||
|
if tp.with_suffix(".py").exists() or tp.exists():
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
# Format description with file info
|
||||||
|
name = Path(f).stem
|
||||||
|
missing[f] = description_template.format(
|
||||||
|
name=name,
|
||||||
|
f=f,
|
||||||
|
test_name=f"test_{name}".replace("-", "_"),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no rule matched, the file is not checked (no test requirement)
|
||||||
|
# This is intentional — only files matching a rule need tests
|
||||||
|
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=_("Check that changed files have corresponding tests"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--staged-only",
|
||||||
|
action="store_true",
|
||||||
|
help=_("Only check staged files (for pre-commit)"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--warn-only",
|
||||||
|
action="store_true",
|
||||||
|
help=_("Print warnings but always exit 0"),
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
repo_root = Path.cwd()
|
||||||
|
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
||||||
|
|
||||||
|
files = _changed_files(args.staged_only, repo_root)
|
||||||
|
if not files:
|
||||||
|
print(_("[check_test_coverage] No changed files to check."))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
||||||
|
if not missing:
|
||||||
|
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
|
||||||
|
for f, reason in missing.items():
|
||||||
|
print(f" {f}", file=sys.stderr)
|
||||||
|
print(f" -> {reason}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.warn_only:
|
||||||
|
return 0
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Clean up old Docker images from a Gitea container registry.
|
||||||
|
|
||||||
|
Queries the Gitea API for all versions of a package (container type) and
|
||||||
|
deletes all but the most recent N versions. The ``latest`` tag is always
|
||||||
|
preserved if present.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Clean up ci-base images, keep last 2 versions
|
||||||
|
python3 -m devx.tools.clean_images \\
|
||||||
|
--owner oblachno-oss \\
|
||||||
|
--name ci-base \\
|
||||||
|
--keep 2
|
||||||
|
|
||||||
|
# Clean up multiple images
|
||||||
|
python3 -m devx.tools.clean_images \\
|
||||||
|
--owner oblachno-oss \\
|
||||||
|
--name ci-base \\
|
||||||
|
--name ci-quality \\
|
||||||
|
--name ci-full \\
|
||||||
|
--keep 2
|
||||||
|
|
||||||
|
# Dry run (list what would be deleted)
|
||||||
|
python3 -m devx.tools.clean_images \\
|
||||||
|
--owner oblachno-oss \\
|
||||||
|
--name ci-base \\
|
||||||
|
--keep 2 \\
|
||||||
|
--dry-run
|
||||||
|
|
||||||
|
Authentication uses ``REPO_TOKEN`` environment variable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devx.config import GITEA_API_URL
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
def list_package_versions(
|
||||||
|
api_url: str,
|
||||||
|
owner: str,
|
||||||
|
name: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List all versions of a container package from the Gitea API.
|
||||||
|
|
||||||
|
Returns a list of version dicts, each containing at least ``version``
|
||||||
|
and ``created_at`` fields.
|
||||||
|
"""
|
||||||
|
url = f"{api_url}/packages/{owner}?type=container&name={name}"
|
||||||
|
headers = {"Authorization": f"token {token}"}
|
||||||
|
all_versions: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{url}&page={page}&limit=50",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
all_versions.extend(data)
|
||||||
|
if len(data) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return all_versions
|
||||||
|
|
||||||
|
|
||||||
|
def delete_package_version(
|
||||||
|
api_url: str,
|
||||||
|
owner: str,
|
||||||
|
name: str,
|
||||||
|
version: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a specific version of a container package.
|
||||||
|
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
"""
|
||||||
|
url = f"{api_url}/packages/{owner}/{name}/{version}"
|
||||||
|
headers = {"Authorization": f"token {token}"}
|
||||||
|
resp = requests.delete(url, headers=headers, timeout=timeout)
|
||||||
|
return resp.status_code in (204, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def sort_versions_by_date(
|
||||||
|
versions: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Sort package versions by creation date, newest first.
|
||||||
|
|
||||||
|
Falls back to version string comparison if created_at is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _sort_key(v: dict[str, Any]) -> str:
|
||||||
|
return str(v.get("created_at", v.get("version", "")))
|
||||||
|
|
||||||
|
return sorted(versions, key=_sort_key, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def select_for_deletion(
|
||||||
|
versions: list[dict[str, Any]],
|
||||||
|
keep: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Select versions to delete, keeping the most recent ``keep`` versions.
|
||||||
|
|
||||||
|
Versions named ``latest`` are always preserved.
|
||||||
|
"""
|
||||||
|
sorted_versions = sort_versions_by_date(versions)
|
||||||
|
to_delete = sorted_versions[keep:]
|
||||||
|
# Always preserve 'latest' tag
|
||||||
|
to_delete = [v for v in to_delete if v.get("version") != "latest"]
|
||||||
|
return to_delete
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--owner",
|
||||||
|
required=True,
|
||||||
|
help="Package owner (user or org).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--name",
|
||||||
|
"names",
|
||||||
|
multiple=True,
|
||||||
|
required=True,
|
||||||
|
help="Package name(s). Can be repeated.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--keep",
|
||||||
|
default=2,
|
||||||
|
type=int,
|
||||||
|
show_default=True,
|
||||||
|
help="Number of recent versions to keep (excluding 'latest').",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="List versions that would be deleted without actually deleting.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--api-url",
|
||||||
|
default=None,
|
||||||
|
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
|
||||||
|
)
|
||||||
|
def main(
|
||||||
|
owner: str,
|
||||||
|
names: tuple[str, ...],
|
||||||
|
keep: int,
|
||||||
|
dry_run: bool,
|
||||||
|
api_url: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Clean up old Docker image versions from a Gitea registry."""
|
||||||
|
token = os.environ.get("REPO_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise click.ClickException(_("REPO_TOKEN environment variable required"))
|
||||||
|
base_url = api_url or GITEA_API_URL
|
||||||
|
|
||||||
|
total_deleted = 0
|
||||||
|
total_kept = 0
|
||||||
|
for name in names:
|
||||||
|
click.echo(f"\n{'=' * 60}")
|
||||||
|
click.echo(f"Package: {owner}/{name}")
|
||||||
|
click.echo(f"{'=' * 60}")
|
||||||
|
try:
|
||||||
|
versions = list_package_versions(base_url, owner, name, token)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
click.echo(
|
||||||
|
_("Failed to list versions for {name}: {error}", name=name, error=exc),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not versions:
|
||||||
|
click.echo(_("No versions found."))
|
||||||
|
continue
|
||||||
|
|
||||||
|
click.echo(f"Found {len(versions)} version(s):")
|
||||||
|
for v in sort_versions_by_date(versions):
|
||||||
|
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
|
||||||
|
|
||||||
|
to_delete = select_for_deletion(versions, keep)
|
||||||
|
kept_count = len(versions) - len(to_delete)
|
||||||
|
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
for v in to_delete:
|
||||||
|
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
|
||||||
|
total_kept += kept_count
|
||||||
|
continue
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
for v in to_delete:
|
||||||
|
version = str(v.get("version", ""))
|
||||||
|
if delete_package_version(base_url, owner, name, version, token):
|
||||||
|
click.echo(f" Deleted: {version}")
|
||||||
|
deleted_count += 1
|
||||||
|
else:
|
||||||
|
click.echo(f" FAILED to delete: {version}", err=True)
|
||||||
|
|
||||||
|
total_deleted += deleted_count
|
||||||
|
total_kept += kept_count
|
||||||
|
|
||||||
|
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main() # pragma: no cover
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a pull request with the correct title from the Vikunja task.
|
||||||
|
|
||||||
|
This tool is run **after** pushing a feature branch. It:
|
||||||
|
|
||||||
|
1. Extracts the task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``).
|
||||||
|
2. Fetches the Vikunja task title for that task ID.
|
||||||
|
3. Creates a PR with title ``{TASK_PREFIX}-N: <vikunja task title>``.
|
||||||
|
|
||||||
|
This eliminates manual PR title entry and ensures the title always
|
||||||
|
matches the Vikunja task — which is what the auto-merge workflow
|
||||||
|
validates.
|
||||||
|
|
||||||
|
If a PR already exists for the branch, the tool prints its URL and
|
||||||
|
exits successfully (idempotent).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.tools.create_pr --branch DEVX-31-fix-foo
|
||||||
|
|
||||||
|
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||||
|
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
import click
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from devx.api_clients import GiteaClient, VikunjaClient
|
||||||
|
from devx.config import (
|
||||||
|
DEFAULT_PER_PAGE,
|
||||||
|
GITEA_API_URL,
|
||||||
|
REPO_OWNER,
|
||||||
|
TASK_ID_RE,
|
||||||
|
TASK_PREFIX,
|
||||||
|
VIKUNJA_API_URL,
|
||||||
|
VIKUNJA_PROJECT_ID,
|
||||||
|
)
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_name() -> str:
|
||||||
|
"""Auto-detect repository name from env vars or git remote."""
|
||||||
|
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
if github_repo and "/" in github_repo:
|
||||||
|
return github_repo.split("/", 1)[1]
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_id(branch: str) -> str:
|
||||||
|
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
|
||||||
|
match = TASK_ID_RE.search(branch)
|
||||||
|
return match.group(0) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_vikunja_task_title(task_id: str) -> str:
|
||||||
|
"""Fetch the Vikunja task title for the given task identifier.
|
||||||
|
|
||||||
|
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
|
||||||
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||||
|
if not tasks:
|
||||||
|
break
|
||||||
|
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||||
|
if matches:
|
||||||
|
return str(matches[0].get("title", ""))
|
||||||
|
if len(tasks) < DEFAULT_PER_PAGE:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||||
|
task_id=task_id,
|
||||||
|
project_id=VIKUNJA_PROJECT_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
|
||||||
|
"""Return an existing open PR for the branch, or None."""
|
||||||
|
prs = client.list_prs(state="open")
|
||||||
|
for pr in prs:
|
||||||
|
if pr.get("head", {}).get("ref") == branch:
|
||||||
|
return pr
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_pr(
|
||||||
|
branch: str,
|
||||||
|
base: str,
|
||||||
|
body: str,
|
||||||
|
repo_owner: str,
|
||||||
|
repo_name: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a PR with the title derived from the Vikunja task.
|
||||||
|
|
||||||
|
Returns the PR dict from the Gitea API.
|
||||||
|
"""
|
||||||
|
task_id = extract_task_id(branch)
|
||||||
|
if not task_id:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
|
||||||
|
branch=branch,
|
||||||
|
prefix=TASK_PREFIX,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
token = os.environ.get("REPO_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR."))
|
||||||
|
|
||||||
|
vikunja_title = get_vikunja_task_title(task_id)
|
||||||
|
pr_title = f"{task_id}: {vikunja_title}"
|
||||||
|
|
||||||
|
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||||
|
|
||||||
|
existing = find_existing_pr(client, branch)
|
||||||
|
if existing:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"PR already exists: #{index} — {url}",
|
||||||
|
index=existing.get("number", "?"),
|
||||||
|
url=existing.get("html_url", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
pr = client.create_pr(title=pr_title, head=branch, base=base, body=body)
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Created PR #{index}: {title}\n {url}",
|
||||||
|
index=pr.get("number", "?"),
|
||||||
|
title=pr_title,
|
||||||
|
url=pr.get("html_url", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return pr
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--branch", default=None, help="Head branch (default: auto-detect from git).")
|
||||||
|
@click.option("--base", default="master", show_default=True, help="Base branch.")
|
||||||
|
@click.option("--body", default="", help="PR body (markdown). Read from stdin if '-' is passed.")
|
||||||
|
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||||
|
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||||
|
def cli(branch: str | None, base: str, body: str, owner: str | None, repo: str | None) -> None:
|
||||||
|
"""Create a PR with the correct title from the Vikunja task."""
|
||||||
|
if branch is None:
|
||||||
|
result = subprocess.run( # nosec
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Could not detect current branch: {error}", error=result.stderr.strip()),
|
||||||
|
)
|
||||||
|
branch = result.stdout.strip()
|
||||||
|
|
||||||
|
if body == "-":
|
||||||
|
body = click.get_text_stream("stdin").read().strip()
|
||||||
|
|
||||||
|
repo_owner = owner or REPO_OWNER
|
||||||
|
if not repo_owner:
|
||||||
|
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||||
|
repo_name = repo or get_repo_name()
|
||||||
|
|
||||||
|
create_pr(branch, base, body, repo_owner, repo_name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a Vikunja task with a detailed HTML description.
|
||||||
|
|
||||||
|
This tool is used during the planning phase of the development workflow
|
||||||
|
to create a well-described task before any code is written. The task
|
||||||
|
identifier (e.g. ``DEVX-N``, ``GRM-N``, ``OBL-INFRA-N``) is then used
|
||||||
|
to name the feature branch and the pull request.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.tools.create_task --title "Add release automation" \\
|
||||||
|
--description "<h2>Overview</h2><p>Implement automated...</p>"
|
||||||
|
|
||||||
|
The project ID and task prefix are read from ``DEVX_VIKUNJA_PROJECT_ID``
|
||||||
|
and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from devx.api_clients import VikunjaClient
|
||||||
|
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--title", required=True, help="Task title (becomes the Vikunja task title).")
|
||||||
|
@click.option(
|
||||||
|
"--description",
|
||||||
|
default="",
|
||||||
|
help="Task description (HTML supported). Read from stdin if '-' is passed.",
|
||||||
|
)
|
||||||
|
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
|
||||||
|
def cli(title: str, description: str, project_id: int | None) -> None:
|
||||||
|
"""Create a Vikunja task and print its identifier."""
|
||||||
|
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment."))
|
||||||
|
|
||||||
|
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
|
||||||
|
|
||||||
|
if description == "-":
|
||||||
|
description = click.get_text_stream("stdin").read().strip()
|
||||||
|
|
||||||
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
|
task = client.create_task(pid, title, description)
|
||||||
|
|
||||||
|
identifier = task.get("identifier", "")
|
||||||
|
task_id = task.get("id", "")
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Created Vikunja task: {identifier} (id={task_id})",
|
||||||
|
identifier=identifier,
|
||||||
|
task_id=task_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if identifier:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Next steps:\n"
|
||||||
|
" 1. git checkout master && git pull\n"
|
||||||
|
" 2. git checkout -b {prefix}-{num}-short-description\n"
|
||||||
|
" 3. Implement changes, commit with conventional commit format\n"
|
||||||
|
" 4. git push -u origin HEAD\n"
|
||||||
|
" 5. make create-pr (creates PR with title: {identifier}: {title})",
|
||||||
|
prefix=TASK_PREFIX,
|
||||||
|
num=identifier.split("-")[-1] if "-" in identifier else "N",
|
||||||
|
identifier=identifier,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pre-push validation: ensure a Vikunja task exists for the branch.
|
||||||
|
|
||||||
|
This tool is designed to run as a git pre-push hook. It extracts the
|
||||||
|
task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``)
|
||||||
|
and verifies that a corresponding Vikunja task exists.
|
||||||
|
|
||||||
|
If the task does not exist, the hook **fails with guidance** — it does
|
||||||
|
not auto-create the task. This prevents accidental pushes of branches
|
||||||
|
without a planning task.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.tools.pre_push_check --branch DEVX-31-fix-foo
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 — all checks passed, safe to push
|
||||||
|
1 — validation failed (missing task, missing token, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
import click
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from devx.api_clients import VikunjaClient
|
||||||
|
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_branch() -> str:
|
||||||
|
"""Return the current git branch name, or empty string on error."""
|
||||||
|
result = subprocess.run( # nosec
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_id(branch: str) -> str:
|
||||||
|
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
|
||||||
|
match = TASK_ID_RE.search(branch)
|
||||||
|
return match.group(0) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def task_exists(task_id: str) -> bool:
|
||||||
|
"""Check if a Vikunja task with the given identifier exists.
|
||||||
|
|
||||||
|
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
|
||||||
|
"""
|
||||||
|
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||||
|
if not tasks:
|
||||||
|
break
|
||||||
|
if any(t.get("identifier") == task_id for t in tasks):
|
||||||
|
return True
|
||||||
|
if len(tasks) < DEFAULT_PER_PAGE:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate(branch: str) -> None:
|
||||||
|
"""Run all pre-push validations for the given branch.
|
||||||
|
|
||||||
|
Raises ``click.ClickException`` on failure.
|
||||||
|
"""
|
||||||
|
if not branch or branch in ("master", "main"):
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = extract_task_id(branch)
|
||||||
|
if not task_id:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Branch '{branch}' does not contain a task ID.\n"
|
||||||
|
" Expected format: {prefix}-N-short-description\n"
|
||||||
|
" Example: {prefix}-42-add-feature\n"
|
||||||
|
" Fix: rename the branch or create a Vikunja task first:\n"
|
||||||
|
' python -m devx.tools.create_task --title "Task title"',
|
||||||
|
branch=branch,
|
||||||
|
prefix=TASK_PREFIX,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
|
||||||
|
"Set it in .env to enable full validation.",
|
||||||
|
),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not task_exists(task_id):
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Vikunja task {task_id} not found in project {project_id}.\n"
|
||||||
|
" Create it first:\n"
|
||||||
|
' python -m devx.tools.create_task --title "Task title"\n'
|
||||||
|
" Or check that the task ID in the branch name is correct.",
|
||||||
|
task_id=task_id,
|
||||||
|
project_id=VIKUNJA_PROJECT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(_("Pre-push check passed: task {task_id} exists.", task_id=task_id))
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--branch", default=None, help="Branch name (default: auto-detect from git).")
|
||||||
|
def cli(branch: str | None) -> None:
|
||||||
|
"""Validate pre-push preconditions for the current branch."""
|
||||||
|
if branch is None:
|
||||||
|
branch = get_current_branch()
|
||||||
|
validate(branch)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
+12
-2
@@ -150,19 +150,29 @@ def _verify(bin_dir: str) -> None:
|
|||||||
default=False,
|
default=False,
|
||||||
help="Skip Ansible Galaxy collection installation.",
|
help="Skip Ansible Galaxy collection installation.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--skip-install",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip pip install (use when deps already installed, e.g. devx came via ci extra).",
|
||||||
|
)
|
||||||
def main(
|
def main(
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
extras: str,
|
extras: str,
|
||||||
no_pre_commit: bool,
|
no_pre_commit: bool,
|
||||||
no_tea_login: bool,
|
no_tea_login: bool,
|
||||||
no_ansible_collections: bool,
|
no_ansible_collections: bool,
|
||||||
|
skip_install: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
|
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
|
||||||
if not Path(bin_dir).exists():
|
if not Path(bin_dir).exists():
|
||||||
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
||||||
|
|
||||||
click.echo(f"Installing Python dependencies (extras: {extras})...")
|
if not skip_install:
|
||||||
_install_python_deps(bin_dir, extras)
|
click.echo(f"Installing Python dependencies (extras: {extras})...")
|
||||||
|
_install_python_deps(bin_dir, extras)
|
||||||
|
else:
|
||||||
|
click.echo("Skipping pip install (--skip-install).")
|
||||||
|
|
||||||
if not no_ansible_collections:
|
if not no_ansible_collections:
|
||||||
click.echo("Installing Ansible Galaxy collections...")
|
click.echo("Installing Ansible Galaxy collections...")
|
||||||
|
|||||||
+528
-8
@@ -343,6 +343,14 @@
|
|||||||
"ru": " Updated: {title}",
|
"ru": " Updated: {title}",
|
||||||
"zh": " Updated: {title}"
|
"zh": " Updated: {title}"
|
||||||
},
|
},
|
||||||
|
"--push requires --registry": {
|
||||||
|
"bg": "--push requires --registry",
|
||||||
|
"de": "--push requires --registry",
|
||||||
|
"en": "--push requires --registry",
|
||||||
|
"pl": "--push requires --registry",
|
||||||
|
"ru": "--push requires --registry",
|
||||||
|
"zh": "--push requires --registry"
|
||||||
|
},
|
||||||
"--skip-build: skipping package build and PyPI publish.": {
|
"--skip-build: skipping package build and PyPI publish.": {
|
||||||
"bg": "--skip-build: skipping package build and PyPI publish.",
|
"bg": "--skip-build: skipping package build and PyPI publish.",
|
||||||
"de": "--skip-build: skipping package build and PyPI publish.",
|
"de": "--skip-build: skipping package build and PyPI publish.",
|
||||||
@@ -367,6 +375,14 @@
|
|||||||
"ru": "API poll warning: {exc}",
|
"ru": "API poll warning: {exc}",
|
||||||
"zh": "API poll warning: {exc}"
|
"zh": "API poll warning: {exc}"
|
||||||
},
|
},
|
||||||
|
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
|
||||||
|
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
|
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
|
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
|
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
|
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
|
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
||||||
|
},
|
||||||
"All molecule tests passed.": {
|
"All molecule tests passed.": {
|
||||||
"bg": "All molecule tests passed.",
|
"bg": "All molecule tests passed.",
|
||||||
"de": "All molecule tests passed.",
|
"de": "All molecule tests passed.",
|
||||||
@@ -383,6 +399,22 @@
|
|||||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||||
},
|
},
|
||||||
|
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
|
||||||
|
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
||||||
|
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
|
||||||
|
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
|
||||||
|
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
|
||||||
|
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
|
||||||
|
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
|
||||||
|
},
|
||||||
|
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
|
||||||
|
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
|
||||||
|
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
|
||||||
|
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
|
||||||
|
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
|
||||||
|
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
||||||
|
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
||||||
|
},
|
||||||
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||||
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||||
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||||
@@ -391,6 +423,38 @@
|
|||||||
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||||
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||||
},
|
},
|
||||||
|
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
|
||||||
|
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
|
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
|
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
|
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
|
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
|
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
|
||||||
|
},
|
||||||
|
"Branch name (e.g., DEVX-256-fix-foo)": {
|
||||||
|
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||||
|
"de": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||||
|
"en": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||||
|
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||||
|
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||||
|
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
|
||||||
|
},
|
||||||
|
"Branch name must contain a task ID.": {
|
||||||
|
"bg": "Branch name must contain a task ID.",
|
||||||
|
"de": "Branch name must contain a task ID.",
|
||||||
|
"en": "Branch name must contain a task ID.",
|
||||||
|
"pl": "Branch name must contain a task ID.",
|
||||||
|
"ru": "Branch name must contain a task ID.",
|
||||||
|
"zh": "Branch name must contain a task ID."
|
||||||
|
},
|
||||||
|
"Build failed for {name}": {
|
||||||
|
"bg": "Build failed for {name}",
|
||||||
|
"de": "Build failed for {name}",
|
||||||
|
"en": "Build failed for {name}",
|
||||||
|
"pl": "Build failed for {name}",
|
||||||
|
"ru": "Build failed for {name}",
|
||||||
|
"zh": "Build failed for {name}"
|
||||||
|
},
|
||||||
"Bumping version: {current} -> v{new_version}": {
|
"Bumping version: {current} -> v{new_version}": {
|
||||||
"bg": "Bumping version: {current} -> v{new_version}",
|
"bg": "Bumping version: {current} -> v{new_version}",
|
||||||
"de": "Bumping version: {current} -> v{new_version}",
|
"de": "Bumping version: {current} -> v{new_version}",
|
||||||
@@ -399,6 +463,14 @@
|
|||||||
"ru": "Bumping version: {current} -> v{new_version}",
|
"ru": "Bumping version: {current} -> v{new_version}",
|
||||||
"zh": "Bumping version: {current} -> v{new_version}"
|
"zh": "Bumping version: {current} -> v{new_version}"
|
||||||
},
|
},
|
||||||
|
"Check that changed files have corresponding tests": {
|
||||||
|
"bg": "Check that changed files have corresponding tests",
|
||||||
|
"de": "Check that changed files have corresponding tests",
|
||||||
|
"en": "Check that changed files have corresponding tests",
|
||||||
|
"pl": "Check that changed files have corresponding tests",
|
||||||
|
"ru": "Check that changed files have corresponding tests",
|
||||||
|
"zh": "Check that changed files have corresponding tests"
|
||||||
|
},
|
||||||
"Checking CLI command documentation...": {
|
"Checking CLI command documentation...": {
|
||||||
"bg": "Checking CLI command documentation...",
|
"bg": "Checking CLI command documentation...",
|
||||||
"de": "Checking CLI command documentation...",
|
"de": "Checking CLI command documentation...",
|
||||||
@@ -423,6 +495,14 @@
|
|||||||
"ru": "Comparing {base}..{head} ({count} files changed)",
|
"ru": "Comparing {base}..{head} ({count} files changed)",
|
||||||
"zh": "Comparing {base}..{head} ({count} files changed)"
|
"zh": "Comparing {base}..{head} ({count} files changed)"
|
||||||
},
|
},
|
||||||
|
"Configuration OK: [tool.devx] present, devx versions consistent.": {
|
||||||
|
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
|
||||||
|
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
|
||||||
|
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
|
||||||
|
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
|
||||||
|
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||||
|
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||||
|
},
|
||||||
"Configuring branch protection for {branch}...": {
|
"Configuring branch protection for {branch}...": {
|
||||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||||
@@ -439,6 +519,14 @@
|
|||||||
"ru": "Настройка параметров репозитория...",
|
"ru": "Настройка параметров репозитория...",
|
||||||
"zh": "正在配置仓库设置..."
|
"zh": "正在配置仓库设置..."
|
||||||
},
|
},
|
||||||
|
"Could not detect current branch: {error}": {
|
||||||
|
"bg": "Не може да се определи текущия клон: {error}",
|
||||||
|
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||||
|
"en": "Could not detect current branch: {error}",
|
||||||
|
"pl": "Nie można wykryć bieżącej gałęzi: {error}",
|
||||||
|
"ru": "Не удалось определить текущую ветку: {error}",
|
||||||
|
"zh": "无法检测当前分支: {error}"
|
||||||
|
},
|
||||||
"Could not extract conventional commit message from PR commits.": {
|
"Could not extract conventional commit message from PR commits.": {
|
||||||
"bg": "Could not extract conventional commit message from PR commits.",
|
"bg": "Could not extract conventional commit message from PR commits.",
|
||||||
"de": "Could not extract conventional commit message from PR commits.",
|
"de": "Could not extract conventional commit message from PR commits.",
|
||||||
@@ -447,6 +535,22 @@
|
|||||||
"ru": "Could not extract conventional commit message from PR commits.",
|
"ru": "Could not extract conventional commit message from PR commits.",
|
||||||
"zh": "Could not extract conventional commit message from PR commits."
|
"zh": "Could not extract conventional commit message from PR commits."
|
||||||
},
|
},
|
||||||
|
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
|
||||||
|
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||||
|
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||||
|
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||||
|
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||||
|
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||||
|
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
|
||||||
|
},
|
||||||
|
"Could not find Vikunja task {task_id} in project {project_id}.": {
|
||||||
|
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
|
||||||
|
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
|
||||||
|
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
|
||||||
|
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
|
||||||
|
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
|
||||||
|
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
|
||||||
|
},
|
||||||
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
||||||
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||||
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||||
@@ -471,6 +575,22 @@
|
|||||||
"ru": "Could not parse test execution time from output.",
|
"ru": "Could not parse test execution time from output.",
|
||||||
"zh": "Could not parse test execution time from output."
|
"zh": "Could not parse test execution time from output."
|
||||||
},
|
},
|
||||||
|
"Created PR #{index}: {title}\n {url}": {
|
||||||
|
"bg": "Създаден PR #{index}: {title}\n {url}",
|
||||||
|
"de": "PR erstellt #{index}: {title}\n {url}",
|
||||||
|
"en": "Created PR #{index}: {title}\n {url}",
|
||||||
|
"pl": "Utworzono PR #{index}: {title}\n {url}",
|
||||||
|
"ru": "Создан PR #{index}: {title}\n {url}",
|
||||||
|
"zh": "已创建 PR #{index}: {title}\n {url}"
|
||||||
|
},
|
||||||
|
"Created Vikunja task: {identifier} (id={task_id})": {
|
||||||
|
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
|
||||||
|
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
|
||||||
|
"en": "Created Vikunja task: {identifier} (id={task_id})",
|
||||||
|
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
|
||||||
|
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
|
||||||
|
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
|
||||||
|
},
|
||||||
"Created issue #{issue_id}: {title}": {
|
"Created issue #{issue_id}: {title}": {
|
||||||
"bg": "Created issue #{issue_id}: {title}",
|
"bg": "Created issue #{issue_id}: {title}",
|
||||||
"de": "Created issue #{issue_id}: {title}",
|
"de": "Created issue #{issue_id}: {title}",
|
||||||
@@ -487,6 +607,14 @@
|
|||||||
"ru": "Created release commit.",
|
"ru": "Created release commit.",
|
||||||
"zh": "Created release commit."
|
"zh": "Created release commit."
|
||||||
},
|
},
|
||||||
|
"Dependencies must have documentation comments.": {
|
||||||
|
"bg": "Dependencies must have documentation comments.",
|
||||||
|
"de": "Dependencies must have documentation comments.",
|
||||||
|
"en": "Dependencies must have documentation comments.",
|
||||||
|
"pl": "Dependencies must have documentation comments.",
|
||||||
|
"ru": "Dependencies must have documentation comments.",
|
||||||
|
"zh": "Dependencies must have documentation comments."
|
||||||
|
},
|
||||||
"Docker daemon already running": {
|
"Docker daemon already running": {
|
||||||
"bg": "Докер демонът вече работи",
|
"bg": "Докер демонът вече работи",
|
||||||
"de": "Docker-Daemon läuft bereits",
|
"de": "Docker-Daemon läuft bereits",
|
||||||
@@ -511,6 +639,14 @@
|
|||||||
"ru": "Docker-демон запущен",
|
"ru": "Docker-демон запущен",
|
||||||
"zh": "Docker 守护进程已启动"
|
"zh": "Docker 守护进程已启动"
|
||||||
},
|
},
|
||||||
|
"Dockerfile not found: {path}": {
|
||||||
|
"bg": "Dockerfile not found: {path}",
|
||||||
|
"de": "Dockerfile not found: {path}",
|
||||||
|
"en": "Dockerfile not found: {path}",
|
||||||
|
"pl": "Dockerfile not found: {path}",
|
||||||
|
"ru": "Dockerfile not found: {path}",
|
||||||
|
"zh": "Dockerfile not found: {path}"
|
||||||
|
},
|
||||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
||||||
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||||
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||||
@@ -559,6 +695,14 @@
|
|||||||
"ru": "ERROR: mapping.json not found at {path}",
|
"ru": "ERROR: mapping.json not found at {path}",
|
||||||
"zh": "ERROR: mapping.json not found at {path}"
|
"zh": "ERROR: mapping.json not found at {path}"
|
||||||
},
|
},
|
||||||
|
"FAILED: {count} undocumented dependency/ies": {
|
||||||
|
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||||
|
"de": "FAILED: {count} undocumented dependency/ies",
|
||||||
|
"en": "FAILED: {count} undocumented dependency/ies",
|
||||||
|
"pl": "FAILED: {count} undocumented dependency/ies",
|
||||||
|
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||||
|
"zh": "FAILED: {count} undocumented dependency/ies"
|
||||||
|
},
|
||||||
"FAILED: {pair} exited with code {code}": {
|
"FAILED: {pair} exited with code {code}": {
|
||||||
"bg": "FAILED: {pair} exited with code {code}",
|
"bg": "FAILED: {pair} exited with code {code}",
|
||||||
"de": "FAILED: {pair} exited with code {code}",
|
"de": "FAILED: {pair} exited with code {code}",
|
||||||
@@ -567,6 +711,14 @@
|
|||||||
"ru": "FAILED: {pair} exited with code {code}",
|
"ru": "FAILED: {pair} exited with code {code}",
|
||||||
"zh": "FAILED: {pair} exited with code {code}"
|
"zh": "FAILED: {pair} exited with code {code}"
|
||||||
},
|
},
|
||||||
|
"Failed images: {names}": {
|
||||||
|
"bg": "Failed images: {names}",
|
||||||
|
"de": "Failed images: {names}",
|
||||||
|
"en": "Failed images: {names}",
|
||||||
|
"pl": "Failed images: {names}",
|
||||||
|
"ru": "Failed images: {names}",
|
||||||
|
"zh": "Failed images: {names}"
|
||||||
|
},
|
||||||
"Failed to create issue via tea: {error}": {
|
"Failed to create issue via tea: {error}": {
|
||||||
"bg": "Failed to create issue via tea: {error}",
|
"bg": "Failed to create issue via tea: {error}",
|
||||||
"de": "Failed to create issue via tea: {error}",
|
"de": "Failed to create issue via tea: {error}",
|
||||||
@@ -575,6 +727,14 @@
|
|||||||
"ru": "Failed to create issue via tea: {error}",
|
"ru": "Failed to create issue via tea: {error}",
|
||||||
"zh": "Failed to create issue via tea: {error}"
|
"zh": "Failed to create issue via tea: {error}"
|
||||||
},
|
},
|
||||||
|
"Failed to list versions for {name}: {error}": {
|
||||||
|
"bg": "Failed to list versions for {name}: {error}",
|
||||||
|
"de": "Failed to list versions for {name}: {error}",
|
||||||
|
"en": "Failed to list versions for {name}: {error}",
|
||||||
|
"pl": "Failed to list versions for {name}: {error}",
|
||||||
|
"ru": "Failed to list versions for {name}: {error}",
|
||||||
|
"zh": "Failed to list versions for {name}: {error}"
|
||||||
|
},
|
||||||
"Found {count} existing wiki pages.": {
|
"Found {count} existing wiki pages.": {
|
||||||
"bg": "Found {count} existing wiki pages.",
|
"bg": "Found {count} existing wiki pages.",
|
||||||
"de": "Found {count} existing wiki pages.",
|
"de": "Found {count} existing wiki pages.",
|
||||||
@@ -583,6 +743,22 @@
|
|||||||
"ru": "Found {count} existing wiki pages.",
|
"ru": "Found {count} existing wiki pages.",
|
||||||
"zh": "Found {count} existing wiki pages."
|
"zh": "Found {count} existing wiki pages."
|
||||||
},
|
},
|
||||||
|
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
|
||||||
|
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||||
|
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||||
|
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||||
|
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||||
|
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||||
|
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
|
||||||
|
},
|
||||||
|
"Found {count} stale documentation reference(s)": {
|
||||||
|
"bg": "Found {count} stale documentation reference(s)",
|
||||||
|
"de": "Found {count} stale documentation reference(s)",
|
||||||
|
"en": "Found {count} stale documentation reference(s)",
|
||||||
|
"pl": "Found {count} stale documentation reference(s)",
|
||||||
|
"ru": "Found {count} stale documentation reference(s)",
|
||||||
|
"zh": "Found {count} stale documentation reference(s)"
|
||||||
|
},
|
||||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||||
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||||
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||||
@@ -671,6 +847,30 @@
|
|||||||
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
|
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
|
||||||
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
|
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
|
||||||
},
|
},
|
||||||
|
"Image 'tags' must be a list": {
|
||||||
|
"bg": "Image 'tags' must be a list",
|
||||||
|
"de": "Image 'tags' must be a list",
|
||||||
|
"en": "Image 'tags' must be a list",
|
||||||
|
"pl": "Image 'tags' must be a list",
|
||||||
|
"ru": "Image 'tags' must be a list",
|
||||||
|
"zh": "Image 'tags' must be a list"
|
||||||
|
},
|
||||||
|
"Image manifest entry missing 'dockerfile'": {
|
||||||
|
"bg": "Image manifest entry missing 'dockerfile'",
|
||||||
|
"de": "Image manifest entry missing 'dockerfile'",
|
||||||
|
"en": "Image manifest entry missing 'dockerfile'",
|
||||||
|
"pl": "Image manifest entry missing 'dockerfile'",
|
||||||
|
"ru": "Image manifest entry missing 'dockerfile'",
|
||||||
|
"zh": "Image manifest entry missing 'dockerfile'"
|
||||||
|
},
|
||||||
|
"Image manifest entry missing 'name'": {
|
||||||
|
"bg": "Image manifest entry missing 'name'",
|
||||||
|
"de": "Image manifest entry missing 'name'",
|
||||||
|
"en": "Image manifest entry missing 'name'",
|
||||||
|
"pl": "Image manifest entry missing 'name'",
|
||||||
|
"ru": "Image manifest entry missing 'name'",
|
||||||
|
"zh": "Image manifest entry missing 'name'"
|
||||||
|
},
|
||||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
||||||
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
|
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
|
||||||
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
|
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
|
||||||
@@ -719,6 +919,22 @@
|
|||||||
"ru": "Lint passed.",
|
"ru": "Lint passed.",
|
||||||
"zh": "Lint passed."
|
"zh": "Lint passed."
|
||||||
},
|
},
|
||||||
|
"Manifest file not found: {path}": {
|
||||||
|
"bg": "Manifest file not found: {path}",
|
||||||
|
"de": "Manifest file not found: {path}",
|
||||||
|
"en": "Manifest file not found: {path}",
|
||||||
|
"pl": "Manifest file not found: {path}",
|
||||||
|
"ru": "Manifest file not found: {path}",
|
||||||
|
"zh": "Manifest file not found: {path}"
|
||||||
|
},
|
||||||
|
"Manifest must be a JSON list": {
|
||||||
|
"bg": "Manifest must be a JSON list",
|
||||||
|
"de": "Manifest must be a JSON list",
|
||||||
|
"en": "Manifest must be a JSON list",
|
||||||
|
"pl": "Manifest must be a JSON list",
|
||||||
|
"ru": "Manifest must be a JSON list",
|
||||||
|
"zh": "Manifest must be a JSON list"
|
||||||
|
},
|
||||||
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
|
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
|
||||||
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||||
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||||
@@ -759,6 +975,14 @@
|
|||||||
"ru": "Директория molecule не найдена: {path}",
|
"ru": "Директория molecule не найдена: {path}",
|
||||||
"zh": "未找到 molecule 目录: {path}"
|
"zh": "未找到 molecule 目录: {path}"
|
||||||
},
|
},
|
||||||
|
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
|
||||||
|
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
|
||||||
|
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
|
||||||
|
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
|
||||||
|
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
|
||||||
|
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
|
||||||
|
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
|
||||||
|
},
|
||||||
"Nice! Gitea release {tag} created.": {
|
"Nice! Gitea release {tag} created.": {
|
||||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
"bg": "Отлично! Gitea release {tag} е създаден.",
|
||||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
"de": "Prima! Gitea-Release {tag} erstellt.",
|
||||||
@@ -831,6 +1055,14 @@
|
|||||||
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||||
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
|
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
|
||||||
},
|
},
|
||||||
|
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
|
||||||
|
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||||
|
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||||
|
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||||
|
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||||
|
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||||
|
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
|
||||||
|
},
|
||||||
"No unreleased changes found. Nothing to release.": {
|
"No unreleased changes found. Nothing to release.": {
|
||||||
"bg": "No unreleased changes found. Nothing to release.",
|
"bg": "No unreleased changes found. Nothing to release.",
|
||||||
"de": "No unreleased changes found. Nothing to release.",
|
"de": "No unreleased changes found. Nothing to release.",
|
||||||
@@ -847,6 +1079,14 @@
|
|||||||
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||||
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||||
},
|
},
|
||||||
|
"No versions found.": {
|
||||||
|
"bg": "No versions found.",
|
||||||
|
"de": "No versions found.",
|
||||||
|
"en": "No versions found.",
|
||||||
|
"pl": "No versions found.",
|
||||||
|
"ru": "No versions found.",
|
||||||
|
"zh": "No versions found."
|
||||||
|
},
|
||||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||||
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||||
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||||
@@ -855,6 +1095,14 @@
|
|||||||
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||||
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
|
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
|
||||||
},
|
},
|
||||||
|
"Only check staged files (for pre-commit)": {
|
||||||
|
"bg": "Only check staged files (for pre-commit)",
|
||||||
|
"de": "Only check staged files (for pre-commit)",
|
||||||
|
"en": "Only check staged files (for pre-commit)",
|
||||||
|
"pl": "Only check staged files (for pre-commit)",
|
||||||
|
"ru": "Only check staged files (for pre-commit)",
|
||||||
|
"zh": "Only check staged files (for pre-commit)"
|
||||||
|
},
|
||||||
"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": {
|
"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",
|
"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",
|
"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",
|
||||||
@@ -943,6 +1191,22 @@
|
|||||||
"ru": "PASSED: {pair}",
|
"ru": "PASSED: {pair}",
|
||||||
"zh": "PASSED: {pair}"
|
"zh": "PASSED: {pair}"
|
||||||
},
|
},
|
||||||
|
"PR already exists: #{index} — {url}": {
|
||||||
|
"bg": "PR вече съществува: #{index} — {url}",
|
||||||
|
"de": "PR existiert bereits: #{index} — {url}",
|
||||||
|
"en": "PR already exists: #{index} — {url}",
|
||||||
|
"pl": "PR już istnieje: #{index} — {url}",
|
||||||
|
"ru": "PR уже существует: #{index} — {url}",
|
||||||
|
"zh": "PR 已存在: #{index} — {url}"
|
||||||
|
},
|
||||||
|
"PR number (to fetch title from Gitea)": {
|
||||||
|
"bg": "PR number (to fetch title from Gitea)",
|
||||||
|
"de": "PR number (to fetch title from Gitea)",
|
||||||
|
"en": "PR number (to fetch title from Gitea)",
|
||||||
|
"pl": "PR number (to fetch title from Gitea)",
|
||||||
|
"ru": "PR number (to fetch title from Gitea)",
|
||||||
|
"zh": "PR number (to fetch title from Gitea)"
|
||||||
|
},
|
||||||
"PR number must be an integer, got: {pr_number}": {
|
"PR number must be an integer, got: {pr_number}": {
|
||||||
"bg": "PR number must be an integer, got: {pr_number}",
|
"bg": "PR number must be an integer, got: {pr_number}",
|
||||||
"de": "PR number must be an integer, got: {pr_number}",
|
"de": "PR number must be an integer, got: {pr_number}",
|
||||||
@@ -951,6 +1215,14 @@
|
|||||||
"ru": "PR number must be an integer, got: {pr_number}",
|
"ru": "PR number must be an integer, got: {pr_number}",
|
||||||
"zh": "PR number must be an integer, got: {pr_number}"
|
"zh": "PR number must be an integer, got: {pr_number}"
|
||||||
},
|
},
|
||||||
|
"PR title (auto-fetched if --pr-number given)": {
|
||||||
|
"bg": "PR title (auto-fetched if --pr-number given)",
|
||||||
|
"de": "PR title (auto-fetched if --pr-number given)",
|
||||||
|
"en": "PR title (auto-fetched if --pr-number given)",
|
||||||
|
"pl": "PR title (auto-fetched if --pr-number given)",
|
||||||
|
"ru": "PR title (auto-fetched if --pr-number given)",
|
||||||
|
"zh": "PR title (auto-fetched if --pr-number given)"
|
||||||
|
},
|
||||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
||||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||||
@@ -959,6 +1231,30 @@
|
|||||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||||
},
|
},
|
||||||
|
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
|
||||||
|
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
|
||||||
|
},
|
||||||
|
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
|
||||||
|
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||||
|
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||||
|
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||||
|
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||||
|
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||||
|
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
|
||||||
|
},
|
||||||
|
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
|
||||||
|
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||||
|
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||||
|
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||||
|
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||||
|
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||||
|
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
|
||||||
|
},
|
||||||
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
"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.",
|
"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.",
|
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
||||||
@@ -975,6 +1271,14 @@
|
|||||||
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
||||||
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
||||||
},
|
},
|
||||||
|
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
|
||||||
|
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||||
|
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||||
|
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||||
|
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||||
|
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||||
|
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
|
||||||
|
},
|
||||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
|
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
|
||||||
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
@@ -983,6 +1287,38 @@
|
|||||||
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
||||||
},
|
},
|
||||||
|
"Pre-merge validation failed.": {
|
||||||
|
"bg": "Pre-merge validation failed.",
|
||||||
|
"de": "Pre-merge validation failed.",
|
||||||
|
"en": "Pre-merge validation failed.",
|
||||||
|
"pl": "Pre-merge validation failed.",
|
||||||
|
"ru": "Pre-merge validation failed.",
|
||||||
|
"zh": "Pre-merge validation failed."
|
||||||
|
},
|
||||||
|
"Pre-push check passed: task {task_id} exists.": {
|
||||||
|
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
|
||||||
|
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
|
||||||
|
"en": "Pre-push check passed: task {task_id} exists.",
|
||||||
|
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
|
||||||
|
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
|
||||||
|
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
|
||||||
|
},
|
||||||
|
"Print warnings but always exit 0": {
|
||||||
|
"bg": "Print warnings but always exit 0",
|
||||||
|
"de": "Print warnings but always exit 0",
|
||||||
|
"en": "Print warnings but always exit 0",
|
||||||
|
"pl": "Print warnings but always exit 0",
|
||||||
|
"ru": "Print warnings but always exit 0",
|
||||||
|
"zh": "Print warnings but always exit 0"
|
||||||
|
},
|
||||||
|
"Provide --manifest or both --dockerfile and --name": {
|
||||||
|
"bg": "Provide --manifest or both --dockerfile and --name",
|
||||||
|
"de": "Provide --manifest or both --dockerfile and --name",
|
||||||
|
"en": "Provide --manifest or both --dockerfile and --name",
|
||||||
|
"pl": "Provide --manifest or both --dockerfile and --name",
|
||||||
|
"ru": "Provide --manifest or both --dockerfile and --name",
|
||||||
|
"zh": "Provide --manifest or both --dockerfile and --name"
|
||||||
|
},
|
||||||
"Provide a commit message file or use --git.": {
|
"Provide a commit message file or use --git.": {
|
||||||
"bg": "Provide a commit message file or use --git.",
|
"bg": "Provide a commit message file or use --git.",
|
||||||
"de": "Provide a commit message file or use --git.",
|
"de": "Provide a commit message file or use --git.",
|
||||||
@@ -1015,6 +1351,14 @@
|
|||||||
"ru": "Publishing release {tag}...",
|
"ru": "Publishing release {tag}...",
|
||||||
"zh": "Publishing release {tag}..."
|
"zh": "Publishing release {tag}..."
|
||||||
},
|
},
|
||||||
|
"Push failed for {tag}: {error}": {
|
||||||
|
"bg": "Push failed for {tag}: {error}",
|
||||||
|
"de": "Push failed for {tag}: {error}",
|
||||||
|
"en": "Push failed for {tag}: {error}",
|
||||||
|
"pl": "Push failed for {tag}: {error}",
|
||||||
|
"ru": "Push failed for {tag}: {error}",
|
||||||
|
"zh": "Push failed for {tag}: {error}"
|
||||||
|
},
|
||||||
"Pushed release commit to master.": {
|
"Pushed release commit to master.": {
|
||||||
"bg": "Pushed release commit to master.",
|
"bg": "Pushed release commit to master.",
|
||||||
"de": "Pushed release commit to master.",
|
"de": "Pushed release commit to master.",
|
||||||
@@ -1031,6 +1375,54 @@
|
|||||||
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
|
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
|
||||||
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
|
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
|
||||||
},
|
},
|
||||||
|
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
||||||
|
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
||||||
|
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||||
|
},
|
||||||
|
"REPO_TOKEN environment variable required": {
|
||||||
|
"bg": "REPO_TOKEN environment variable required",
|
||||||
|
"de": "REPO_TOKEN environment variable required",
|
||||||
|
"en": "REPO_TOKEN environment variable required",
|
||||||
|
"pl": "REPO_TOKEN environment variable required",
|
||||||
|
"ru": "REPO_TOKEN environment variable required",
|
||||||
|
"zh": "REPO_TOKEN environment variable required"
|
||||||
|
},
|
||||||
|
"REPO_TOKEN is not set. Required to create a PR.": {
|
||||||
|
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||||
|
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||||
|
"en": "REPO_TOKEN is not set. Required to create a PR.",
|
||||||
|
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
|
||||||
|
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
|
||||||
|
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
|
||||||
|
},
|
||||||
|
"Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars": {
|
||||||
|
"bg": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||||
|
"de": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||||
|
"en": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||||
|
"pl": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||||
|
"ru": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||||
|
"zh": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars"
|
||||||
|
},
|
||||||
|
"Registry login failed": {
|
||||||
|
"bg": "Registry login failed",
|
||||||
|
"de": "Registry login failed",
|
||||||
|
"en": "Registry login failed",
|
||||||
|
"pl": "Registry login failed",
|
||||||
|
"ru": "Registry login failed",
|
||||||
|
"zh": "Registry login failed"
|
||||||
|
},
|
||||||
|
"Registry login failed: {error}": {
|
||||||
|
"bg": "Registry login failed: {error}",
|
||||||
|
"de": "Registry login failed: {error}",
|
||||||
|
"en": "Registry login failed: {error}",
|
||||||
|
"pl": "Registry login failed: {error}",
|
||||||
|
"ru": "Registry login failed: {error}",
|
||||||
|
"zh": "Registry login failed: {error}"
|
||||||
|
},
|
||||||
"Release creation failed: {error}": {
|
"Release creation failed: {error}": {
|
||||||
"bg": "Release creation failed: {error}",
|
"bg": "Release creation failed: {error}",
|
||||||
"de": "Release creation failed: {error}",
|
"de": "Release creation failed: {error}",
|
||||||
@@ -1063,6 +1455,30 @@
|
|||||||
"ru": "Конфигурация репозитория завершена.",
|
"ru": "Конфигурация репозитория завершена.",
|
||||||
"zh": "仓库配置完成。"
|
"zh": "仓库配置完成。"
|
||||||
},
|
},
|
||||||
|
"Repository in owner/name format": {
|
||||||
|
"bg": "Repository in owner/name format",
|
||||||
|
"de": "Repository in owner/name format",
|
||||||
|
"en": "Repository in owner/name format",
|
||||||
|
"pl": "Repository in owner/name format",
|
||||||
|
"ru": "Repository in owner/name format",
|
||||||
|
"zh": "Repository in owner/name format"
|
||||||
|
},
|
||||||
|
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
|
||||||
|
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||||
|
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
|
||||||
|
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
|
||||||
|
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
|
||||||
|
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||||
|
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
|
||||||
|
},
|
||||||
|
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||||
|
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||||
|
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||||
|
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
|
||||||
|
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
|
||||||
|
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
||||||
|
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||||
|
},
|
||||||
"Roles directory not found: {path}": {
|
"Roles directory not found: {path}": {
|
||||||
"bg": "Roles directory not found: {path}",
|
"bg": "Roles directory not found: {path}",
|
||||||
"de": "Roles directory not found: {path}",
|
"de": "Roles directory not found: {path}",
|
||||||
@@ -1103,6 +1519,22 @@
|
|||||||
"ru": "Running: {scenario} on {platform}",
|
"ru": "Running: {scenario} on {platform}",
|
||||||
"zh": "Running: {scenario} on {platform}"
|
"zh": "Running: {scenario} on {platform}"
|
||||||
},
|
},
|
||||||
|
"Skip Vikunja title match check": {
|
||||||
|
"bg": "Skip Vikunja title match check",
|
||||||
|
"de": "Skip Vikunja title match check",
|
||||||
|
"en": "Skip Vikunja title match check",
|
||||||
|
"pl": "Skip Vikunja title match check",
|
||||||
|
"ru": "Skip Vikunja title match check",
|
||||||
|
"zh": "Skip Vikunja title match check"
|
||||||
|
},
|
||||||
|
"Skip branch-behind-master check": {
|
||||||
|
"bg": "Skip branch-behind-master check",
|
||||||
|
"de": "Skip branch-behind-master check",
|
||||||
|
"en": "Skip branch-behind-master check",
|
||||||
|
"pl": "Skip branch-behind-master check",
|
||||||
|
"ru": "Skip branch-behind-master check",
|
||||||
|
"zh": "Skip branch-behind-master check"
|
||||||
|
},
|
||||||
"Skipping commit push — no staged changes.": {
|
"Skipping commit push — no staged changes.": {
|
||||||
"bg": "Skipping commit push — no staged changes.",
|
"bg": "Skipping commit push — no staged changes.",
|
||||||
"de": "Skipping commit push — no staged changes.",
|
"de": "Skipping commit push — no staged changes.",
|
||||||
@@ -1135,14 +1567,6 @@
|
|||||||
"ru": "Tag is required (or use --from-tag).",
|
"ru": "Tag is required (or use --from-tag).",
|
||||||
"zh": "Tag is required (or use --from-tag)."
|
"zh": "Tag is required (or use --from-tag)."
|
||||||
},
|
},
|
||||||
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
|
||||||
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
|
||||||
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
|
||||||
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
|
||||||
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
|
||||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
|
||||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
|
||||||
},
|
|
||||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||||
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||||
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||||
@@ -1239,6 +1663,22 @@
|
|||||||
"ru": "Updated {changelog_file}",
|
"ru": "Updated {changelog_file}",
|
||||||
"zh": "Updated {changelog_file}"
|
"zh": "Updated {changelog_file}"
|
||||||
},
|
},
|
||||||
|
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
|
||||||
|
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
|
||||||
|
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
|
||||||
|
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
|
||||||
|
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
|
||||||
|
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
|
||||||
|
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
|
||||||
|
},
|
||||||
|
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
|
||||||
|
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
|
||||||
|
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
|
||||||
|
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
|
||||||
|
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
|
||||||
|
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
|
||||||
|
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
|
||||||
|
},
|
||||||
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
|
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
|
||||||
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||||
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||||
@@ -1263,6 +1703,14 @@
|
|||||||
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||||
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
|
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
|
||||||
},
|
},
|
||||||
|
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
|
||||||
|
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
|
||||||
|
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
|
||||||
|
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
|
||||||
|
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
|
||||||
|
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
|
||||||
|
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
|
||||||
|
},
|
||||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||||
"bg": "WARNING: --skip-tests passed — skipping test verification.",
|
"bg": "WARNING: --skip-tests passed — skipping test verification.",
|
||||||
"de": "WARNING: --skip-tests passed — skipping test verification.",
|
"de": "WARNING: --skip-tests passed — skipping test verification.",
|
||||||
@@ -1279,6 +1727,14 @@
|
|||||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||||
},
|
},
|
||||||
|
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||||
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||||
|
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||||
|
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
|
||||||
|
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
|
||||||
|
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||||
|
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||||
|
},
|
||||||
"Warning: could not fetch tags from origin.": {
|
"Warning: could not fetch tags from origin.": {
|
||||||
"bg": "Warning: could not fetch tags from origin.",
|
"bg": "Warning: could not fetch tags from origin.",
|
||||||
"de": "Warning: could not fetch tags from origin.",
|
"de": "Warning: could not fetch tags from origin.",
|
||||||
@@ -1303,6 +1759,46 @@
|
|||||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||||
},
|
},
|
||||||
|
"Wrote tag {tag} to GITHUB_OUTPUT.": {
|
||||||
|
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
|
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
|
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
|
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
|
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
|
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||||
|
},
|
||||||
|
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||||
|
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||||
|
"de": "[check-dep-docs] Passed: all dependencies are documented",
|
||||||
|
"en": "[check-dep-docs] Passed: all dependencies are documented",
|
||||||
|
"pl": "[check-dep-docs] Passed: all dependencies are documented",
|
||||||
|
"ru": "[check-dep-docs] Passed: all dependencies are documented",
|
||||||
|
"zh": "[check-dep-docs] Passed: all dependencies are documented"
|
||||||
|
},
|
||||||
|
"[check-mutable-globals] Passed: no mutable path globals found": {
|
||||||
|
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||||
|
"de": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||||
|
"en": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||||
|
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||||
|
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||||
|
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
|
||||||
|
},
|
||||||
|
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
|
||||||
|
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||||
|
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||||
|
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||||
|
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||||
|
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||||
|
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
|
||||||
|
},
|
||||||
|
"[check_test_coverage] No changed files to check.": {
|
||||||
|
"bg": "[check_test_coverage] No changed files to check.",
|
||||||
|
"de": "[check_test_coverage] No changed files to check.",
|
||||||
|
"en": "[check_test_coverage] No changed files to check.",
|
||||||
|
"pl": "[check_test_coverage] No changed files to check.",
|
||||||
|
"ru": "[check_test_coverage] No changed files to check.",
|
||||||
|
"zh": "[check_test_coverage] No changed files to check."
|
||||||
|
},
|
||||||
"[dry-run] Would commit: release: v{version}": {
|
"[dry-run] Would commit: release: v{version}": {
|
||||||
"bg": "[dry-run] Would commit: release: v{version}",
|
"bg": "[dry-run] Would commit: release: v{version}",
|
||||||
"de": "[dry-run] Would commit: release: v{version}",
|
"de": "[dry-run] Would commit: release: v{version}",
|
||||||
@@ -1359,6 +1855,14 @@
|
|||||||
"ru": "[dry-run] Would update {init}",
|
"ru": "[dry-run] Would update {init}",
|
||||||
"zh": "[dry-run] Would update {init}"
|
"zh": "[dry-run] Would update {init}"
|
||||||
},
|
},
|
||||||
|
"[tool.devx] missing required keys: {keys}": {
|
||||||
|
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||||
|
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||||
|
"en": "[tool.devx] missing required keys: {keys}",
|
||||||
|
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
|
||||||
|
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
|
||||||
|
"zh": "[tool.devx] 缺少必需的键: {keys}"
|
||||||
|
},
|
||||||
"active": {
|
"active": {
|
||||||
"bg": "активен",
|
"bg": "активен",
|
||||||
"de": "aktiv",
|
"de": "aktiv",
|
||||||
@@ -1375,6 +1879,14 @@
|
|||||||
"ru": "завершён",
|
"ru": "завершён",
|
||||||
"zh": "已完成"
|
"zh": "已完成"
|
||||||
},
|
},
|
||||||
|
"devx version mismatch across extras: {detail}": {
|
||||||
|
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||||
|
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||||
|
"en": "devx version mismatch across extras: {detail}",
|
||||||
|
"pl": "niezgodność wersji devx między extras: {detail}",
|
||||||
|
"ru": "несоответствие версии devx между extras: {detail}",
|
||||||
|
"zh": "devx 版本在 extras 之间不一致: {detail}"
|
||||||
|
},
|
||||||
"failed": {
|
"failed": {
|
||||||
"bg": "неуспешен",
|
"bg": "неуспешен",
|
||||||
"de": "fehlgeschlagen",
|
"de": "fehlgeschlagen",
|
||||||
@@ -1455,6 +1967,14 @@
|
|||||||
"ru": "ожидает",
|
"ru": "ожидает",
|
||||||
"zh": "待处理"
|
"zh": "待处理"
|
||||||
},
|
},
|
||||||
|
"pyproject.toml not found in current directory.": {
|
||||||
|
"bg": "pyproject.toml не е намерен в текущата директория.",
|
||||||
|
"de": "pyproject.toml im aktuellen Verzeichnis nicht gefunden.",
|
||||||
|
"en": "pyproject.toml not found in current directory.",
|
||||||
|
"pl": "nie znaleziono pyproject.toml w bieżącym katalogu.",
|
||||||
|
"ru": "pyproject.toml не найден в текущей директории.",
|
||||||
|
"zh": "在当前目录中未找到 pyproject.toml。"
|
||||||
|
},
|
||||||
"unknown": {
|
"unknown": {
|
||||||
"bg": "неизвестен",
|
"bg": "неизвестен",
|
||||||
"de": "unbekannt",
|
"de": "unbekannt",
|
||||||
|
|||||||
@@ -137,6 +137,19 @@ class TestGiteaClient:
|
|||||||
assert result is None
|
assert result is None
|
||||||
client.create_label.assert_not_called()
|
client.create_label.assert_not_called()
|
||||||
|
|
||||||
|
def test_ensure_label_creates_when_others_exist(self) -> None:
|
||||||
|
"""When labels exist but none match the target name, create a new one."""
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client.list_labels = MagicMock(
|
||||||
|
return_value=[{"name": "bug", "color": "ff0000"}, {"name": "docs", "color": "007ec6"}]
|
||||||
|
)
|
||||||
|
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
|
||||||
|
|
||||||
|
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||||
|
assert result is not None
|
||||||
|
assert result["name"] == "ready-to-merge"
|
||||||
|
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
|
||||||
|
|
||||||
def test_list_branch_protections(self) -> None:
|
def test_list_branch_protections(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(
|
client._session.request = MagicMock(
|
||||||
@@ -196,6 +209,18 @@ class TestGiteaClient:
|
|||||||
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
|
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)
|
client.update_branch_protection.assert_called_once_with("master", expected_update)
|
||||||
|
|
||||||
|
def test_ensure_branch_protection_creates_when_none_match(self) -> None:
|
||||||
|
"""When existing protections exist but none match the target branch, create a new one."""
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client.list_branch_protections = MagicMock(
|
||||||
|
return_value=[{"branch_name": "develop"}, {"branch_name": "staging"}]
|
||||||
|
)
|
||||||
|
client.create_branch_protection = MagicMock(return_value={"id": 5, "branch_name": "master"})
|
||||||
|
|
||||||
|
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
|
||||||
|
assert result["id"] == 5
|
||||||
|
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
|
||||||
|
|
||||||
def test_merge_pr(self) -> None:
|
def test_merge_pr(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(return_value=_mock_response())
|
client._session.request = MagicMock(return_value=_mock_response())
|
||||||
@@ -248,6 +273,37 @@ class TestGiteaClient:
|
|||||||
timeout=DEFAULT_TIMEOUT,
|
timeout=DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_create_pr(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response({"number": 15, "html_url": "https://git.example.com/pr/15"})
|
||||||
|
)
|
||||||
|
result = client.create_pr(title="DEVX-42: Add feature", head="DEVX-42-fix", body="desc")
|
||||||
|
assert result["number"] == 15
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"POST",
|
||||||
|
"https://git.example.com/repos/owner/repo/pulls",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
json={"title": "DEVX-42: Add feature", "head": "DEVX-42-fix", "base": "master", "body": "desc"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_pr_no_body(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response({"number": 16, "html_url": "https://git.example.com/pr/16"})
|
||||||
|
)
|
||||||
|
result = client.create_pr(title="DEVX-43: Fix bug", head="DEVX-43-fix")
|
||||||
|
assert result["number"] == 16
|
||||||
|
call_kwargs = client._session.request.call_args.kwargs
|
||||||
|
assert "body" not in call_kwargs["json"]
|
||||||
|
|
||||||
|
def test_create_pr_custom_base(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(return_value=_mock_response({"number": 17}))
|
||||||
|
client.create_pr(title="Test", head="branch", base="develop")
|
||||||
|
call_kwargs = client._session.request.call_args.kwargs
|
||||||
|
assert call_kwargs["json"]["base"] == "develop"
|
||||||
|
|
||||||
def test_get_pr_files(self) -> None:
|
def test_get_pr_files(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(
|
client._session.request = MagicMock(
|
||||||
@@ -701,6 +757,30 @@ class TestVikunjaClient:
|
|||||||
assert exc_info.value.status == 0
|
assert exc_info.value.status == 0
|
||||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||||
|
|
||||||
|
def test_vikunja_create_task(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response({"id": 1, "identifier": "DEVX-1", "title": "Test"})
|
||||||
|
)
|
||||||
|
result = client.create_task(6, "Test", "<p>desc</p>")
|
||||||
|
assert result["identifier"] == "DEVX-1"
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"PUT",
|
||||||
|
"https://work.example.com/projects/6/tasks",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
json={"title": "Test", "description": "<p>desc</p>"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vikunja_create_task_no_description(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response({"id": 2, "identifier": "DEVX-2", "title": "No desc"})
|
||||||
|
)
|
||||||
|
result = client.create_task(6, "No desc")
|
||||||
|
assert result["id"] == 2
|
||||||
|
call_kwargs = client._session.request.call_args.kwargs
|
||||||
|
assert call_kwargs["json"]["description"] == ""
|
||||||
|
|
||||||
|
|
||||||
class TestIsRetryable:
|
class TestIsRetryable:
|
||||||
def test_connection_error_is_retryable(self) -> None:
|
def test_connection_error_is_retryable(self) -> None:
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class TestReadTaskid:
|
|||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "WARNING" not in captured.out
|
assert "WARNING" not in captured.out
|
||||||
|
|
||||||
|
def test_no_warning_when_taskid_file_matches_branch(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""No warning when .taskid file content matches the branch task ID."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||||
|
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "WARNING" not in captured.out
|
||||||
|
|
||||||
|
def test_no_warning_when_taskid_file_empty(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""No warning when .taskid file exists but is empty."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
(tmp_path / ".taskid").write_text("\n")
|
||||||
|
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "WARNING" not in captured.out
|
||||||
|
|
||||||
|
|
||||||
# -- extract_task_id (legacy fallback) --
|
# -- extract_task_id (legacy fallback) --
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
"""Unit tests for devx.tools.build_image."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click import ClickException
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import devx.tools.build_image as build_image
|
||||||
|
from devx.tools.build_image import (
|
||||||
|
ImageSpec,
|
||||||
|
build_full_tag,
|
||||||
|
load_manifest,
|
||||||
|
push_image,
|
||||||
|
registry_login,
|
||||||
|
)
|
||||||
|
from devx.tools.build_image import (
|
||||||
|
build_image as do_build,
|
||||||
|
)
|
||||||
|
from devx.tools.clean_images import select_for_deletion, sort_versions_by_date
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageSpec:
|
||||||
|
def test_from_dict_minimal(self) -> None:
|
||||||
|
spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"})
|
||||||
|
assert spec.name == "ci-base"
|
||||||
|
assert spec.dockerfile == "docker/ci-base/Dockerfile"
|
||||||
|
assert spec.context == "."
|
||||||
|
assert spec.tags == ["latest"]
|
||||||
|
|
||||||
|
def test_from_dict_full(self) -> None:
|
||||||
|
spec = ImageSpec.from_dict(
|
||||||
|
{
|
||||||
|
"name": "ci-quality",
|
||||||
|
"dockerfile": "docker/ci-quality/Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tags": ["latest", "0.19.3"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert spec.name == "ci-quality"
|
||||||
|
assert spec.dockerfile == "docker/ci-quality/Dockerfile"
|
||||||
|
assert spec.context == "."
|
||||||
|
assert spec.tags == ["latest", "0.19.3"]
|
||||||
|
|
||||||
|
def test_from_dict_missing_name(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="missing 'name'"):
|
||||||
|
ImageSpec.from_dict({"dockerfile": "Dockerfile"})
|
||||||
|
|
||||||
|
def test_from_dict_missing_dockerfile(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="missing 'dockerfile'"):
|
||||||
|
ImageSpec.from_dict({"name": "ci-base"})
|
||||||
|
|
||||||
|
def test_from_dict_tags_not_list(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="tags.*must be a list"):
|
||||||
|
ImageSpec.from_dict(
|
||||||
|
{
|
||||||
|
"name": "ci-base",
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"tags": "latest",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_from_dict_empty_tags_defaults_to_latest(self) -> None:
|
||||||
|
spec = ImageSpec.from_dict(
|
||||||
|
{
|
||||||
|
"name": "ci-base",
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert spec.tags == ["latest"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFullTag:
|
||||||
|
def test_no_registry(self) -> None:
|
||||||
|
assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest"
|
||||||
|
|
||||||
|
def test_with_registry(self) -> None:
|
||||||
|
assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0"
|
||||||
|
|
||||||
|
def test_with_registry_and_path(self) -> None:
|
||||||
|
assert (
|
||||||
|
build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadManifest:
|
||||||
|
def test_load_valid_manifest(self, tmp_path: Path) -> None:
|
||||||
|
manifest = tmp_path / "images.json"
|
||||||
|
manifest.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"},
|
||||||
|
{"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
specs = load_manifest(manifest)
|
||||||
|
assert len(specs) == 2
|
||||||
|
assert specs[0].name == "ci-base"
|
||||||
|
assert specs[1].tags == ["latest", "1.0"]
|
||||||
|
|
||||||
|
def test_load_missing_file(self, tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ClickException, match="not found"):
|
||||||
|
load_manifest(tmp_path / "nonexistent.json")
|
||||||
|
|
||||||
|
def test_load_not_a_list(self, tmp_path: Path) -> None:
|
||||||
|
manifest = tmp_path / "images.json"
|
||||||
|
manifest.write_text(json.dumps({"name": "ci-base"}))
|
||||||
|
with pytest.raises(ClickException, match="must be a JSON list"):
|
||||||
|
load_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistryLogin:
|
||||||
|
def test_success(self) -> None:
|
||||||
|
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
assert registry_login("git.example.com", "user", "token") is True
|
||||||
|
assert mock_run.call_args.args[0] == [
|
||||||
|
"docker",
|
||||||
|
"login",
|
||||||
|
"git.example.com",
|
||||||
|
"-u",
|
||||||
|
"user",
|
||||||
|
"--password-stdin",
|
||||||
|
]
|
||||||
|
assert mock_run.call_args.kwargs["input"] == "token"
|
||||||
|
|
||||||
|
def test_failure(self) -> None:
|
||||||
|
mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||||
|
assert registry_login("git.example.com", "user", "bad") is False
|
||||||
|
|
||||||
|
def test_dry_run(self) -> None:
|
||||||
|
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||||
|
assert registry_login("git.example.com", "user", "token", dry_run=True) is True
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildImage:
|
||||||
|
def test_success(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"])
|
||||||
|
mock_result = MagicMock(returncode=0)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||||
|
assert do_build(spec) is True
|
||||||
|
|
||||||
|
def test_dockerfile_not_found(self) -> None:
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile")
|
||||||
|
assert do_build(spec) is False
|
||||||
|
|
||||||
|
def test_build_failure(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
|
||||||
|
mock_result = MagicMock(returncode=1)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||||
|
assert do_build(spec) is False
|
||||||
|
|
||||||
|
def test_dry_run(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"])
|
||||||
|
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||||
|
assert do_build(spec, dry_run=True) is True
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
def test_with_registry(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"])
|
||||||
|
mock_result = MagicMock(returncode=0)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
assert do_build(spec, registry="git.example.com") is True
|
||||||
|
cmd = mock_run.call_args.args[0]
|
||||||
|
assert "-t" in cmd
|
||||||
|
idx = cmd.index("-t")
|
||||||
|
assert cmd[idx + 1] == "git.example.com/ci-base:latest"
|
||||||
|
|
||||||
|
def test_pull_flag(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
|
||||||
|
mock_result = MagicMock(returncode=0)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
assert do_build(spec, pull=True) is True
|
||||||
|
cmd = mock_run.call_args.args[0]
|
||||||
|
assert "--pull" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushImage:
|
||||||
|
def test_success(self) -> None:
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
||||||
|
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
assert push_image(spec, "git.example.com") is True
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
def test_partial_failure(self) -> None:
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
||||||
|
results = [
|
||||||
|
MagicMock(returncode=0, stderr="", stdout=""),
|
||||||
|
MagicMock(returncode=1, stderr="push failed", stdout=""),
|
||||||
|
]
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
|
||||||
|
assert push_image(spec, "git.example.com") is False
|
||||||
|
|
||||||
|
def test_dry_run(self) -> None:
|
||||||
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
|
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||||
|
assert push_image(spec, "git.example.com", dry_run=True) is True
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSortVersions:
|
||||||
|
def test_sort_by_created_at_desc(self) -> None:
|
||||||
|
versions = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"},
|
||||||
|
{"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"},
|
||||||
|
]
|
||||||
|
result = sort_versions_by_date(versions)
|
||||||
|
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
|
||||||
|
|
||||||
|
def test_sort_fallback_to_version(self) -> None:
|
||||||
|
versions = [
|
||||||
|
{"version": "0.1.0"},
|
||||||
|
{"version": "0.3.0"},
|
||||||
|
{"version": "0.2.0"},
|
||||||
|
]
|
||||||
|
result = sort_versions_by_date(versions)
|
||||||
|
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectForDeletion:
|
||||||
|
def test_keep_2(self) -> None:
|
||||||
|
versions = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||||
|
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||||
|
{"version": "0.4.0", "created_at": "2025-04-01"},
|
||||||
|
]
|
||||||
|
to_delete = select_for_deletion(versions, keep=2)
|
||||||
|
assert len(to_delete) == 2
|
||||||
|
assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"}
|
||||||
|
|
||||||
|
def test_preserve_latest_tag(self) -> None:
|
||||||
|
versions = [
|
||||||
|
{"version": "latest", "created_at": "2025-01-01"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||||
|
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||||
|
{"version": "0.4.0", "created_at": "2025-04-01"},
|
||||||
|
]
|
||||||
|
to_delete = select_for_deletion(versions, keep=2)
|
||||||
|
deleted_versions = {v["version"] for v in to_delete}
|
||||||
|
assert "latest" not in deleted_versions
|
||||||
|
# latest is oldest by date but still preserved
|
||||||
|
assert "0.2.0" in deleted_versions
|
||||||
|
|
||||||
|
def test_keep_all(self) -> None:
|
||||||
|
versions = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||||
|
]
|
||||||
|
to_delete = select_for_deletion(versions, keep=2)
|
||||||
|
assert len(to_delete) == 0
|
||||||
|
|
||||||
|
def test_keep_more_than_available(self) -> None:
|
||||||
|
versions = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||||
|
]
|
||||||
|
to_delete = select_for_deletion(versions, keep=5)
|
||||||
|
assert len(to_delete) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanImagesAPI:
|
||||||
|
"""Tests for the clean_images module's API functions."""
|
||||||
|
|
||||||
|
def test_list_package_versions(self) -> None:
|
||||||
|
from devx.tools.clean_images import list_package_versions
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = [{"version": "0.1.0"}]
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get:
|
||||||
|
versions = list_package_versions(
|
||||||
|
"https://git.example.com/api/v1",
|
||||||
|
"oblachno-oss",
|
||||||
|
"ci-base",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
assert versions == [{"version": "0.1.0"}]
|
||||||
|
assert "page=1" in mock_get.call_args.args[0]
|
||||||
|
|
||||||
|
def test_list_package_versions_pagination(self) -> None:
|
||||||
|
from devx.tools.clean_images import list_package_versions
|
||||||
|
|
||||||
|
# First page: 50 items, second page: 3 items, third page: empty
|
||||||
|
page1 = [{"version": f"0.{i}.0"} for i in range(50)]
|
||||||
|
page2 = [{"version": f"1.{i}.0"} for i in range(3)]
|
||||||
|
responses = [
|
||||||
|
MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()),
|
||||||
|
MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()),
|
||||||
|
MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()),
|
||||||
|
]
|
||||||
|
with patch("devx.tools.clean_images.requests.get", side_effect=responses):
|
||||||
|
versions = list_package_versions(
|
||||||
|
"https://git.example.com/api/v1",
|
||||||
|
"oblachno-oss",
|
||||||
|
"ci-base",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
assert len(versions) == 53
|
||||||
|
|
||||||
|
def test_delete_package_version_success(self) -> None:
|
||||||
|
from devx.tools.clean_images import delete_package_version
|
||||||
|
|
||||||
|
mock_resp = MagicMock(status_code=204)
|
||||||
|
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||||
|
assert (
|
||||||
|
delete_package_version(
|
||||||
|
"https://git.example.com/api/v1",
|
||||||
|
"oblachno-oss",
|
||||||
|
"ci-base",
|
||||||
|
"0.1.0",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_package_version_failure(self) -> None:
|
||||||
|
from devx.tools.clean_images import delete_package_version
|
||||||
|
|
||||||
|
mock_resp = MagicMock(status_code=404)
|
||||||
|
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||||
|
assert (
|
||||||
|
delete_package_version(
|
||||||
|
"https://git.example.com/api/v1",
|
||||||
|
"oblachno-oss",
|
||||||
|
"ci-base",
|
||||||
|
"0.1.0",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLIBuildImage:
|
||||||
|
def test_single_image_build(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
mock_result = MagicMock(returncode=0)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_manifest_build(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
manifest = tmp_path / "images.json"
|
||||||
|
manifest.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "ci-base", "dockerfile": str(dockerfile)},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
mock_result = MagicMock(returncode=0)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--manifest", str(manifest)],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_missing_dockerfile_and_manifest(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(build_image.main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
|
||||||
|
|
||||||
|
def test_push_without_registry(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "registry" in result.output.lower()
|
||||||
|
|
||||||
|
def test_push_without_credentials(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "credential" in result.output.lower() or "token" in result.output.lower()
|
||||||
|
|
||||||
|
def test_dry_run(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
assert "dry-run" in result.output
|
||||||
|
|
||||||
|
def test_build_failure_exits_with_error(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
mock_result = MagicMock(returncode=1)
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_push_login_failure(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
|
||||||
|
with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "login" in result.output.lower()
|
||||||
|
|
||||||
|
def test_push_image_failure(self, tmp_path: Path) -> None:
|
||||||
|
dockerfile = tmp_path / "Dockerfile"
|
||||||
|
dockerfile.touch()
|
||||||
|
runner = CliRunner()
|
||||||
|
build_result = MagicMock(returncode=0)
|
||||||
|
login_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
|
push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
|
||||||
|
with patch(
|
||||||
|
"devx.tools.build_image.subprocess.run",
|
||||||
|
side_effect=[login_result, build_result, push_result],
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
build_image.main,
|
||||||
|
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLICleanImages:
|
||||||
|
def test_dry_run(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||||
|
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||||
|
]
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||||
|
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "dry-run" in result.output
|
||||||
|
assert "0.1.0" in result.output
|
||||||
|
|
||||||
|
def test_no_token(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--owner", "oblachno-oss", "--name", "ci-base"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "token" in result.output.lower()
|
||||||
|
|
||||||
|
def test_no_versions_found(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = []
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||||
|
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No versions" in result.output
|
||||||
|
|
||||||
|
def test_actual_delete(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
list_resp = MagicMock()
|
||||||
|
list_resp.json.return_value = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||||
|
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||||
|
]
|
||||||
|
list_resp.raise_for_status = MagicMock()
|
||||||
|
delete_resp = MagicMock(status_code=204)
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||||
|
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
|
||||||
|
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Deleted" in result.output
|
||||||
|
|
||||||
|
def test_list_request_exception(self) -> None:
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||||
|
with patch(
|
||||||
|
"devx.tools.clean_images.requests.get",
|
||||||
|
side_effect=req.ConnectionError("network down"),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Failed to list" in result.output
|
||||||
|
|
||||||
|
def test_delete_failure_in_cli(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
list_resp = MagicMock()
|
||||||
|
list_resp.json.return_value = [
|
||||||
|
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||||
|
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||||
|
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||||
|
]
|
||||||
|
list_resp.raise_for_status = MagicMock()
|
||||||
|
delete_resp = MagicMock(status_code=500)
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||||
|
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
|
||||||
|
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "FAILED" in result.output
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Unit tests for devx.tools.check_agent_docs."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_agent_docs import (
|
||||||
|
DEFAULT_REPO_PATH_PREFIXES,
|
||||||
|
DEFAULT_SCAN_DIRS,
|
||||||
|
DEFAULT_SCAN_EXTENSIONS,
|
||||||
|
DEFAULT_SCAN_FILES,
|
||||||
|
MIN_PATH_REF_LENGTH_DEFAULT,
|
||||||
|
_check_file,
|
||||||
|
_collect_doc_files,
|
||||||
|
_is_legitimate_ref,
|
||||||
|
_should_skip,
|
||||||
|
cli,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldSkip:
|
||||||
|
def test_skips_excluded_path(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "docs" / "retrospectives" / "r.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("")
|
||||||
|
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is True
|
||||||
|
|
||||||
|
def test_does_not_skip_normal(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "docs" / "guide.md"
|
||||||
|
f.parent.mkdir(parents=True)
|
||||||
|
f.write_text("")
|
||||||
|
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is False
|
||||||
|
|
||||||
|
def test_returns_false_for_path_outside_repo(self, tmp_path: Path) -> None:
|
||||||
|
f = Path("/tmp/some_other_path/guide.md")
|
||||||
|
assert _should_skip(f, [], tmp_path) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsLegitimateRef:
|
||||||
|
def test_legitimate_legacy(self) -> None:
|
||||||
|
assert _is_legitimate_ref("This is legacy code", ["legacy"]) is True
|
||||||
|
|
||||||
|
def test_not_legitimate(self) -> None:
|
||||||
|
assert _is_legitimate_ref("Use this file", ["legacy"]) is False
|
||||||
|
|
||||||
|
def test_case_insensitive(self) -> None:
|
||||||
|
assert _is_legitimate_ref("This is LEGACY", ["legacy"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectDocFiles:
|
||||||
|
def test_collects_devin_and_docs(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / ".devin").mkdir()
|
||||||
|
(tmp_path / ".devin" / "guide.md").write_text("")
|
||||||
|
(tmp_path / "docs").mkdir()
|
||||||
|
(tmp_path / "docs" / "api.md").write_text("")
|
||||||
|
(tmp_path / "README.md").write_text("")
|
||||||
|
|
||||||
|
files = _collect_doc_files(tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
|
||||||
|
names = {f.name for f in files}
|
||||||
|
assert "guide.md" in names
|
||||||
|
assert "api.md" in names
|
||||||
|
assert "README.md" in names
|
||||||
|
|
||||||
|
def test_excludes_paths(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "docs" / "retrospectives").mkdir(parents=True)
|
||||||
|
(tmp_path / "docs" / "retrospectives" / "r.md").write_text("")
|
||||||
|
(tmp_path / "docs" / "guide.md").write_text("")
|
||||||
|
|
||||||
|
files = _collect_doc_files(
|
||||||
|
tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, ["docs/retrospectives"]
|
||||||
|
)
|
||||||
|
names = {f.name for f in files}
|
||||||
|
assert "guide.md" in names
|
||||||
|
assert "r.md" not in names
|
||||||
|
|
||||||
|
def test_deduplicates(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "docs").mkdir()
|
||||||
|
(tmp_path / "docs" / "api.md").write_text("")
|
||||||
|
|
||||||
|
files = _collect_doc_files(tmp_path, ["docs", "docs"], DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
|
||||||
|
assert len(files) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckFile:
|
||||||
|
def test_detects_deleted_file_ref(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("See scripts/old.py for details.\n")
|
||||||
|
issues = _check_file(
|
||||||
|
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
|
||||||
|
)
|
||||||
|
assert any("deleted file" in i for i in issues)
|
||||||
|
|
||||||
|
def test_detects_nonexistent_file_ref(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("See scripts/nonexistent.py for details.\n")
|
||||||
|
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||||
|
assert any("non-existent file" in i for i in issues)
|
||||||
|
|
||||||
|
def test_does_not_flag_existing_file(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "scripts").mkdir()
|
||||||
|
(tmp_path / "scripts" / "exists.py").write_text("")
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("See scripts/exists.py for details.\n")
|
||||||
|
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_detects_deprecated_pattern(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n")
|
||||||
|
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
|
||||||
|
issues = _check_file(
|
||||||
|
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
|
||||||
|
)
|
||||||
|
assert any("deprecated pattern" in i for i in issues)
|
||||||
|
|
||||||
|
def test_legitimate_ref_skips_deprecated(self, tmp_path: Path) -> None:
|
||||||
|
# Create the referenced file so the non-existent check doesn't trigger
|
||||||
|
secrets = tmp_path / "ansible" / "envs" / "prod" / "secrets.yml"
|
||||||
|
secrets.parent.mkdir(parents=True)
|
||||||
|
secrets.write_text("")
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n")
|
||||||
|
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
|
||||||
|
issues = _check_file(
|
||||||
|
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
|
||||||
|
)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_unicode_error_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_bytes(b"\xff\xfe\x00\x00")
|
||||||
|
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_skips_short_ref(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("See a.py for details.\n")
|
||||||
|
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5, [])
|
||||||
|
# "a.py" is only 4 chars, below min_path_ref_length
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_skips_ref_without_repo_prefix(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("See vendor/some/long/path.py for details.\n")
|
||||||
|
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||||
|
# "vendor/" is not in repo_path_prefixes
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_skip_ref_prefixes_skips_nonexistent(self, tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "docs" / "guide.md"
|
||||||
|
doc.parent.mkdir(parents=True)
|
||||||
|
doc.write_text("See scripts/test_foo.py for details.\n")
|
||||||
|
issues = _check_file(
|
||||||
|
doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, ["scripts/test_"]
|
||||||
|
)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "docs").mkdir()
|
||||||
|
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_agent_docs._load_config", return_value={}),
|
||||||
|
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Passed" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_stale_ref(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "docs").mkdir()
|
||||||
|
(tmp_path / "docs" / "guide.md").write_text("See scripts/deleted.py\n")
|
||||||
|
cfg = {"deleted_files": ["scripts/deleted.py"]}
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
|
||||||
|
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "FAILED" in result.output
|
||||||
|
|
||||||
|
def test_load_config_returns_empty_when_not_dict(self) -> None:
|
||||||
|
from devx.tools.check_agent_docs import _load_config
|
||||||
|
|
||||||
|
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": "not a dict"}):
|
||||||
|
assert _load_config() == {}
|
||||||
|
|
||||||
|
def test_load_config_returns_dict_when_valid(self) -> None:
|
||||||
|
from devx.tools.check_agent_docs import _load_config
|
||||||
|
|
||||||
|
cfg = {"scan_dirs": ["custom"]}
|
||||||
|
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": cfg}):
|
||||||
|
assert _load_config() == cfg
|
||||||
|
|
||||||
|
def test_invalid_regex_pattern_skipped(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "docs").mkdir()
|
||||||
|
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
|
||||||
|
cfg = {"deprecated_patterns": ["[invalid"]}
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
|
||||||
|
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_custom_scan_dirs(self, tmp_path: Path) -> None:
|
||||||
|
custom = tmp_path / "custom_docs"
|
||||||
|
custom.mkdir()
|
||||||
|
(custom / "guide.md").write_text("See scripts/deleted.py\n")
|
||||||
|
cfg = {"scan_dirs": ["custom_docs"], "deleted_files": ["scripts/deleted.py"]}
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
|
||||||
|
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Unit tests for devx.ci.check_auto_merge_ready."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci.check_auto_merge_ready import (
|
||||||
|
cli,
|
||||||
|
get_pr_title_from_gitea,
|
||||||
|
get_vikunja_title_optional,
|
||||||
|
is_branch_behind_master,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsBranchBehindMaster:
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_false_when_ahead(self, mock_run: MagicMock) -> None:
|
||||||
|
# First: fetch (ok), second: ahead count (ok), third: behind count = 0
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="3\n", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="0\n", stderr=""),
|
||||||
|
]
|
||||||
|
assert is_branch_behind_master("feature") is False
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_true_when_behind(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="0\n", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="5\n", stderr=""),
|
||||||
|
]
|
||||||
|
assert is_branch_behind_master("feature") is True
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_false_on_git_error(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr="error"),
|
||||||
|
]
|
||||||
|
assert is_branch_behind_master("feature") is False
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_false_on_timeout(self, mock_run: MagicMock) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=30)
|
||||||
|
assert is_branch_behind_master("feature") is False
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_false_on_value_error(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="3\n", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="not_a_number\n", stderr=""),
|
||||||
|
]
|
||||||
|
assert is_branch_behind_master("feature") is False
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_false_on_file_not_found(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.side_effect = FileNotFoundError("git not found")
|
||||||
|
assert is_branch_behind_master("feature") is False
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
|
||||||
|
def test_returns_false_when_behind_check_fails(self, mock_run: MagicMock) -> None:
|
||||||
|
# fetch ok, ahead count ok, behind count command fails
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="3\n", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr="error"),
|
||||||
|
]
|
||||||
|
assert is_branch_behind_master("feature") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPrTitleFromGitea:
|
||||||
|
def test_returns_none_without_token(self) -> None:
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
assert get_pr_title_from_gitea("owner/repo", 1) is None
|
||||||
|
|
||||||
|
def test_returns_none_with_invalid_repo(self) -> None:
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||||
|
assert get_pr_title_from_gitea("invalid", 1) is None
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||||
|
def test_fetches_title(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||||
|
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||||
|
assert result == "DEVX-1: Fix bug"
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||||
|
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_pr.side_effect = Exception("API error")
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||||
|
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetVikunjaTitleOptional:
|
||||||
|
def test_returns_none_without_token(self) -> None:
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
assert get_vikunja_title_optional("DEVX-1") is None
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||||
|
def test_returns_title_when_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-1", "title": "Fix bug"}]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||||
|
result = get_vikunja_title_optional("DEVX-1")
|
||||||
|
assert result == "Fix bug"
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||||
|
def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-2", "title": "Other task"}]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||||
|
result = get_vikunja_title_optional("DEVX-1")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||||
|
def test_paginates_until_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
from devx.config import DEFAULT_PER_PAGE
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
# First page: full page of non-matching tasks, second page: match
|
||||||
|
page1 = [{"identifier": f"DEVX-{i}", "title": f"Task {i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||||
|
page2 = [{"identifier": "DEVX-99", "title": "Found it"}]
|
||||||
|
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||||
|
result = get_vikunja_title_optional("DEVX-99")
|
||||||
|
assert result == "Found it"
|
||||||
|
|
||||||
|
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
|
||||||
|
def test_returns_none_when_empty_first_page(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = []
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
|
||||||
|
result = get_vikunja_title_optional("DEVX-1")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_fails_without_task_id(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True):
|
||||||
|
result = runner.invoke(cli, ["--branch", "no-task-id-here"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_local_mode_no_pr_title(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "local mode" in result.output
|
||||||
|
|
||||||
|
def test_validates_pr_title_format(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "Bad title"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "format" in result.output.lower() or "mismatch" in result.output.lower()
|
||||||
|
|
||||||
|
def test_passes_with_valid_title(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "satisfied" in result.output
|
||||||
|
|
||||||
|
def test_skip_behind_check(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-behind-check"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_fails_when_behind_master(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "behind" in result.output.lower()
|
||||||
|
|
||||||
|
def test_skip_vikunja(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-vikunja"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_fetches_pr_title_from_gitea(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value="DEVX-1: Fix foo"),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "from Gitea" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_pr_number_but_no_title(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Could not fetch" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_vikunja_token_set_but_task_not_found(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value=None),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Could not find Vikunja task" in result.output
|
||||||
|
|
||||||
|
def test_passes_with_vikunja_title_match(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Fix foo"),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Vikunja title match OK" in result.output
|
||||||
|
|
||||||
|
def test_fails_with_vikunja_title_mismatch(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "does not match Vikunja" in result.output
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Unit tests for devx.tools.check_config."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_config import cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckConfig:
|
||||||
|
def test_valid_config(self, tmp_path: Path) -> None:
|
||||||
|
"""A valid [tool.devx] section with consistent versions passes."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
'[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Configuration OK" in result.output
|
||||||
|
|
||||||
|
def test_missing_tool_devx_section(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing [tool.devx] section fails with error."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "missing required keys" in result.output
|
||||||
|
|
||||||
|
def test_partial_tool_devx_section(self, tmp_path: Path) -> None:
|
||||||
|
"""Partial [tool.devx] section fails with missing keys."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n')
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "missing required keys" in result.output
|
||||||
|
assert "vikunja_project_id" in result.output
|
||||||
|
assert "repo_owner" in result.output
|
||||||
|
assert "repo_name" in result.output
|
||||||
|
|
||||||
|
def test_version_mismatch(self, tmp_path: Path) -> None:
|
||||||
|
"""Version mismatch across extras fails."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
"[project.optional-dependencies]\n"
|
||||||
|
'ci = ["devx>=0.15.0"]\n'
|
||||||
|
'dev = ["devx>=0.14.2"]\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "version mismatch" in result.output
|
||||||
|
|
||||||
|
def test_no_pyproject_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing pyproject.toml fails."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "not found" in result.output
|
||||||
|
|
||||||
|
def test_no_extras_passes(self, tmp_path: Path) -> None:
|
||||||
|
"""No optional-dependencies with devx is fine (no versions to compare)."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Configuration OK" in result.output
|
||||||
|
|
||||||
|
def test_single_extra_passes(self, tmp_path: Path) -> None:
|
||||||
|
"""Single extra with devx version is fine (no mismatch possible)."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
'[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 0
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Unit tests for devx.tools.check_mutable_globals."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_mutable_globals import (
|
||||||
|
DEFAULT_SCAN_DIRS,
|
||||||
|
DEFAULT_SKIP_DIRS,
|
||||||
|
_load_config,
|
||||||
|
_should_skip,
|
||||||
|
cli,
|
||||||
|
find_mutable_globals,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindMutableGlobals:
|
||||||
|
def test_detects_set_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||||
|
source = "_SEEN: set[Path] = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "_SEEN" in issues[0]
|
||||||
|
assert "set()" in issues[0]
|
||||||
|
|
||||||
|
def test_detects_dict_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||||
|
source = "_CACHE: dict[Path, Any] = {}\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "_CACHE" in issues[0]
|
||||||
|
|
||||||
|
def test_detects_list_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||||
|
source = "PATHS: list[Path] = []\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "PATHS" in issues[0]
|
||||||
|
|
||||||
|
def test_skips_non_mutable_globals(self, tmp_path: Path) -> None:
|
||||||
|
source = "_MAX: int = 10\n_SEEN: set[Path] = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "_SEEN" in issues[0]
|
||||||
|
|
||||||
|
def test_skips_globals_without_path_hint(self, tmp_path: Path) -> None:
|
||||||
|
source = "_DATA: dict[str, int] = {}\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 0
|
||||||
|
|
||||||
|
def test_detects_path_type_annotation(self, tmp_path: Path) -> None:
|
||||||
|
source = "_FILES: set[Path] = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
|
||||||
|
def test_known_safe_exception(self, tmp_path: Path) -> None:
|
||||||
|
source = "_SEEN: set[Path] = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
known_safe = {("mod.py", 1, "_SEEN")}
|
||||||
|
issues = find_mutable_globals(f, tmp_path, known_safe)
|
||||||
|
assert len(issues) == 0
|
||||||
|
|
||||||
|
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text("def broken(:\n")
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_detects_mutable_literal_dict(self, tmp_path: Path) -> None:
|
||||||
|
source = "_CACHE: dict[Path, Any] = {}\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
|
||||||
|
def test_detects_mutable_literal_list(self, tmp_path: Path) -> None:
|
||||||
|
source = "SEEN_PATHS: list[Path] = []\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
|
||||||
|
def test_detects_mutable_literal_set(self, tmp_path: Path) -> None:
|
||||||
|
source = "REGISTRY: set[Path] = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
|
||||||
|
def test_skips_function_definitions(self, tmp_path: Path) -> None:
|
||||||
|
source = "def foo():\n pass\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_handles_assign_with_name_target(self, tmp_path: Path) -> None:
|
||||||
|
source = "SEEN_PATHS = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "SEEN_PATHS" in issues[0]
|
||||||
|
|
||||||
|
def test_skips_annotation_without_value(self, tmp_path: Path) -> None:
|
||||||
|
source = "_CACHE: dict[Path, Any]\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_skips_attribute_call(self, tmp_path: Path) -> None:
|
||||||
|
# collections.defaultdict is an Attribute call, not a Name call
|
||||||
|
source = "_CACHE: dict[Path, Any] = collections.defaultdict(list)\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
# Attribute calls are skipped (pass), so not flagged as mutable literal
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_multiple_assign_targets(self, tmp_path: Path) -> None:
|
||||||
|
source = "SEEN = CACHE = set()\n"
|
||||||
|
f = tmp_path / "mod.py"
|
||||||
|
f.write_text(source)
|
||||||
|
issues = find_mutable_globals(f, tmp_path, set())
|
||||||
|
# Both SEEN and CACHE should be flagged
|
||||||
|
assert len(issues) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldSkip:
|
||||||
|
def test_skips_pycache(self) -> None:
|
||||||
|
assert _should_skip(Path("/a/__pycache__/b.py"), DEFAULT_SKIP_DIRS) is True
|
||||||
|
|
||||||
|
def test_skips_venv(self) -> None:
|
||||||
|
assert _should_skip(Path("/a/.venv/b.py"), DEFAULT_SKIP_DIRS) is True
|
||||||
|
|
||||||
|
def test_does_not_skip_normal(self) -> None:
|
||||||
|
assert _should_skip(Path("/a/src/b.py"), DEFAULT_SKIP_DIRS) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig:
|
||||||
|
def test_defaults_when_no_pyproject(self, tmp_path: Path) -> None:
|
||||||
|
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value={}):
|
||||||
|
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||||
|
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||||
|
assert skip_dirs == DEFAULT_SKIP_DIRS
|
||||||
|
assert known_safe == set()
|
||||||
|
|
||||||
|
def test_reads_config_from_pyproject(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"check_mutable_globals": {
|
||||||
|
"scan_dirs": ["src", "tests"],
|
||||||
|
"skip_dirs": ["__pycache__", ".tox"],
|
||||||
|
"known_safe": ["src/mod.py:10:_CACHE"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||||
|
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||||
|
assert scan_dirs == ["src", "tests"]
|
||||||
|
assert ".tox" in skip_dirs
|
||||||
|
assert ("src/mod.py", 10, "_CACHE") in known_safe
|
||||||
|
|
||||||
|
def test_returns_defaults_when_cfg_not_dict(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"devx.tools.check_mutable_globals._load_pyproject_devx",
|
||||||
|
return_value={"check_mutable_globals": "not a dict"},
|
||||||
|
):
|
||||||
|
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||||
|
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||||
|
assert skip_dirs == DEFAULT_SKIP_DIRS
|
||||||
|
assert known_safe == set()
|
||||||
|
|
||||||
|
def test_known_safe_with_invalid_line_number(self) -> None:
|
||||||
|
cfg = {"check_mutable_globals": {"known_safe": ["mod.py:abc:_CACHE"]}}
|
||||||
|
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||||
|
_, _, known_safe = _load_config()
|
||||||
|
assert known_safe == set()
|
||||||
|
|
||||||
|
def test_scan_dirs_not_list_returns_default(self) -> None:
|
||||||
|
cfg = {"check_mutable_globals": {"scan_dirs": "not a list"}}
|
||||||
|
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||||
|
scan_dirs, _, _ = _load_config()
|
||||||
|
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_mutable_globals._load_config", return_value=(["empty_dir"], set(), set())),
|
||||||
|
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Passed" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_issues_found(self, tmp_path: Path) -> None:
|
||||||
|
scan_dir = tmp_path / "src"
|
||||||
|
scan_dir.mkdir()
|
||||||
|
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], set(), set())),
|
||||||
|
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "FAILED" in result.output
|
||||||
|
|
||||||
|
def test_scan_dir_option_overrides_config(self, tmp_path: Path) -> None:
|
||||||
|
scan_dir = tmp_path / "custom"
|
||||||
|
scan_dir.mkdir()
|
||||||
|
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_mutable_globals._load_config", return_value=(["other"], set(), set())),
|
||||||
|
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, ["--scan-dir", "custom"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "FAILED" in result.output
|
||||||
|
|
||||||
|
def test_skips_files_in_skip_dirs(self, tmp_path: Path) -> None:
|
||||||
|
scan_dir = tmp_path / "src"
|
||||||
|
pycache = scan_dir / "__pycache__"
|
||||||
|
pycache.mkdir(parents=True)
|
||||||
|
(pycache / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], {"__pycache__"}, set())),
|
||||||
|
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Passed" in result.output
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Unit tests for devx.tools.check_pyproject_deps."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_pyproject_deps import check_deps, cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckDeps:
|
||||||
|
def test_no_issues_when_all_documented(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# HTTP client
|
||||||
|
"requests>=2.0"
|
||||||
|
# CLI framework
|
||||||
|
"click>=8.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_finds_undocumented_dependency(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# HTTP client
|
||||||
|
"requests>=2.0"
|
||||||
|
"click>=8.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "click" in issues[0]
|
||||||
|
|
||||||
|
def test_finds_multiple_undocumented(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
"requests>=2.0"
|
||||||
|
"click>=8.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert len(issues) == 2
|
||||||
|
|
||||||
|
def test_handles_optional_dependencies(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.optional-dependencies]
|
||||||
|
ci = [
|
||||||
|
# Test runner
|
||||||
|
"pytest>=8",
|
||||||
|
"pytest-cov>=4",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "pytest-cov" in issues[0]
|
||||||
|
|
||||||
|
def test_returns_file_not_found_for_missing_file(self, tmp_path: Path) -> None:
|
||||||
|
issues = check_deps(tmp_path / "nonexistent.toml")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "not found" in issues[0]
|
||||||
|
|
||||||
|
def test_empty_deps_section_no_issues(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_skips_non_deps_sections(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project]
|
||||||
|
name = "test"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[project.dependencies]
|
||||||
|
# HTTP
|
||||||
|
"requests>=2.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_handles_dash_prefixed_deps(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# HTTP client
|
||||||
|
-requests>=2.0
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_empty_lines_in_deps_section(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
"requests>=2.0"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
"click>=8.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_non_dep_non_comment_line_resets_prev(self, tmp_path: Path) -> None:
|
||||||
|
# A line that's not a comment, not a dep, not empty — resets prev_was_comment
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# Comment
|
||||||
|
ci = [
|
||||||
|
"requests>=2.0",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
# "requests" is preceded by a comment, but the `ci = [` line resets prev_was_comment
|
||||||
|
# Actually `ci = [` doesn't start with - or ", so it hits the else branch
|
||||||
|
assert len(issues) == 1
|
||||||
|
|
||||||
|
def test_section_transition_exits_deps(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# HTTP
|
||||||
|
"requests>=2.0"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Test runner
|
||||||
|
"pytest>=8"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
# Both deps are documented
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_deps_after_other_section_not_checked(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project]
|
||||||
|
name = "test"
|
||||||
|
|
||||||
|
[project.dependencies]
|
||||||
|
# Documented
|
||||||
|
"requests>=2.0"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
"undocumented-dep>=1.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
issues = check_deps(f)
|
||||||
|
# The "undocumented-dep" is in [tool.ruff], not a deps section
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_passes_when_all_documented(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# HTTP client
|
||||||
|
"requests>=2.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
with __import__("contextlib").chdir(tmp_path):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Passed" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_undocumented(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
"requests>=2.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "pyproject.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
with __import__("contextlib").chdir(tmp_path):
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "FAILED" in result.output
|
||||||
|
|
||||||
|
def test_custom_file_option(self, tmp_path: Path) -> None:
|
||||||
|
content = """\
|
||||||
|
[project.dependencies]
|
||||||
|
# Documented
|
||||||
|
"requests>=2.0"
|
||||||
|
"""
|
||||||
|
f = tmp_path / "custom.toml"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--file", str(f)])
|
||||||
|
assert result.exit_code == 0
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Unit tests for devx.tools.check_test_coverage."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from devx.tools.check_test_coverage import (
|
||||||
|
BUILTIN_RULES,
|
||||||
|
DEFAULT_SKIP_EXTENSIONS,
|
||||||
|
DEFAULT_TEST_INDICATORS,
|
||||||
|
_changed_files,
|
||||||
|
_find_missing_tests,
|
||||||
|
_is_test_file,
|
||||||
|
_load_rules,
|
||||||
|
_resolve_test_path,
|
||||||
|
_should_skip_file,
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTestFile:
|
||||||
|
def test_tests_dir(self) -> None:
|
||||||
|
assert _is_test_file("tests/unit/test_foo.py", DEFAULT_TEST_INDICATORS) is True
|
||||||
|
|
||||||
|
def test_test_prefix(self) -> None:
|
||||||
|
assert _is_test_file("src/test_foo.py", DEFAULT_TEST_INDICATORS) is True
|
||||||
|
|
||||||
|
def test_test_suffix(self) -> None:
|
||||||
|
assert _is_test_file("src/foo_test.py", DEFAULT_TEST_INDICATORS) is True
|
||||||
|
|
||||||
|
def test_non_test_file(self) -> None:
|
||||||
|
assert _is_test_file("src/foo.py", DEFAULT_TEST_INDICATORS) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldSkipFile:
|
||||||
|
def test_skips_dotfiles(self) -> None:
|
||||||
|
assert _should_skip_file(".gitignore", [], DEFAULT_SKIP_EXTENSIONS) is True
|
||||||
|
|
||||||
|
def test_skips_markdown(self) -> None:
|
||||||
|
assert _should_skip_file("README.md", [], DEFAULT_SKIP_EXTENSIONS) is True
|
||||||
|
|
||||||
|
def test_skips_yaml(self) -> None:
|
||||||
|
assert _should_skip_file("config.yml", [], DEFAULT_SKIP_EXTENSIONS) is True
|
||||||
|
|
||||||
|
def test_does_not_skip_python(self) -> None:
|
||||||
|
assert _should_skip_file("src/foo.py", [], DEFAULT_SKIP_EXTENSIONS) is False
|
||||||
|
|
||||||
|
def test_skips_by_pattern(self) -> None:
|
||||||
|
assert _should_skip_file("src/__init__.py", ["__init__.py"], DEFAULT_SKIP_EXTENSIONS) is True
|
||||||
|
|
||||||
|
def test_skips_by_glob_pattern(self) -> None:
|
||||||
|
assert _should_skip_file("src/config.py", ["config.py"], DEFAULT_SKIP_EXTENSIONS) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveTestPath:
|
||||||
|
def test_resolves_name(self, tmp_path: Path) -> None:
|
||||||
|
result = _resolve_test_path("tests/unit/test_{name}", "src/foo.py", tmp_path)
|
||||||
|
assert result == tmp_path / "tests" / "unit" / "test_foo"
|
||||||
|
|
||||||
|
def test_resolves_module(self, tmp_path: Path) -> None:
|
||||||
|
result = _resolve_test_path("tests/unit/test_{module}_{name}", "src/pkg/foo.py", tmp_path)
|
||||||
|
assert result == tmp_path / "tests" / "unit" / "test_pkg_foo"
|
||||||
|
|
||||||
|
def test_resolves_package_prefix(self, tmp_path: Path) -> None:
|
||||||
|
result = _resolve_test_path(
|
||||||
|
"tests/unit/test_{package_prefix}_{name}",
|
||||||
|
"scripts/utils/secrets.py",
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
assert result == tmp_path / "tests" / "unit" / "test_utils_secrets"
|
||||||
|
|
||||||
|
def test_normalizes_hyphens(self, tmp_path: Path) -> None:
|
||||||
|
result = _resolve_test_path("tests/test_{name}", "scripts/my-script.py", tmp_path)
|
||||||
|
assert result == tmp_path / "tests" / "test_my_script"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindMissingTests:
|
||||||
|
def test_finds_missing_test(self, tmp_path: Path) -> None:
|
||||||
|
files = ["scripts/foo.py"]
|
||||||
|
rules = BUILTIN_RULES
|
||||||
|
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||||
|
assert "scripts/foo.py" in missing
|
||||||
|
|
||||||
|
def test_no_missing_when_test_exists(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||||
|
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
|
||||||
|
files = ["scripts/foo.py"]
|
||||||
|
rules = BUILTIN_RULES
|
||||||
|
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||||
|
assert missing == {}
|
||||||
|
|
||||||
|
def test_skips_test_files(self, tmp_path: Path) -> None:
|
||||||
|
files = ["tests/unit/test_foo.py"]
|
||||||
|
rules = BUILTIN_RULES
|
||||||
|
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||||
|
assert missing == {}
|
||||||
|
|
||||||
|
def test_skips_non_python_files(self, tmp_path: Path) -> None:
|
||||||
|
files = ["README.md", "config.yml"]
|
||||||
|
rules = BUILTIN_RULES
|
||||||
|
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||||
|
assert missing == {}
|
||||||
|
|
||||||
|
def test_no_rule_no_requirement(self, tmp_path: Path) -> None:
|
||||||
|
files = ["unknown_type.xyz"]
|
||||||
|
rules = BUILTIN_RULES
|
||||||
|
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||||
|
assert missing == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangedFiles:
|
||||||
|
@patch("devx.tools.check_test_coverage.subprocess.run")
|
||||||
|
def test_staged_only(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n", returncode=0)
|
||||||
|
files = _changed_files(staged_only=True, repo_root=tmp_path)
|
||||||
|
assert files == ["file1.py", "file2.py"]
|
||||||
|
cmd = mock_run.call_args.args[0]
|
||||||
|
assert "--cached" in cmd
|
||||||
|
|
||||||
|
@patch("devx.tools.check_test_coverage.subprocess.run")
|
||||||
|
def test_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="file1.py\n", returncode=0)
|
||||||
|
files = _changed_files(staged_only=False, repo_root=tmp_path)
|
||||||
|
assert files == ["file1.py"]
|
||||||
|
cmd = mock_run.call_args.args[0]
|
||||||
|
assert "origin/master...HEAD" in cmd
|
||||||
|
|
||||||
|
@patch("devx.tools.check_test_coverage.subprocess.run")
|
||||||
|
def test_fallback_to_staged(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
# First call fails, second succeeds
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(stdout="", returncode=1),
|
||||||
|
MagicMock(stdout="file1.py\n", returncode=0),
|
||||||
|
]
|
||||||
|
files = _changed_files(staged_only=False, repo_root=tmp_path)
|
||||||
|
assert files == ["file1.py"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadRules:
|
||||||
|
def test_defaults_when_no_config(self) -> None:
|
||||||
|
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value={}):
|
||||||
|
rules, skip, indicators, skip_ext = _load_rules()
|
||||||
|
assert rules == BUILTIN_RULES
|
||||||
|
assert skip == []
|
||||||
|
assert indicators == DEFAULT_TEST_INDICATORS
|
||||||
|
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
|
||||||
|
|
||||||
|
def test_custom_rules(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"check_test_coverage": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"source_pattern": "lib/*.py",
|
||||||
|
"test_paths": ["tests/test_{name}"],
|
||||||
|
"description": "Missing: tests/test_{name}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skip_patterns": ["__init__.py"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
|
||||||
|
rules, skip, indicators, skip_ext = _load_rules()
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert rules[0]["source_pattern"] == "lib/*.py"
|
||||||
|
assert "__init__.py" in skip
|
||||||
|
|
||||||
|
def test_returns_defaults_when_cfg_not_dict(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"devx.tools.check_test_coverage._load_pyproject_devx", return_value={"check_test_coverage": "not a dict"}
|
||||||
|
):
|
||||||
|
rules, skip, indicators, skip_ext = _load_rules()
|
||||||
|
assert rules == BUILTIN_RULES
|
||||||
|
assert skip == []
|
||||||
|
|
||||||
|
def test_skip_extensions_not_list_returns_default(self) -> None:
|
||||||
|
cfg = {"check_test_coverage": {"skip_extensions": "not a list"}}
|
||||||
|
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
|
||||||
|
_, _, _, skip_ext = _load_rules()
|
||||||
|
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
|
||||||
|
|
||||||
|
def test_test_paths_not_list_skips_rule(self, tmp_path: Path) -> None:
|
||||||
|
files = ["scripts/foo.py"]
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
"source_pattern": "scripts/*.py",
|
||||||
|
"test_paths": "not a list",
|
||||||
|
"description": "Missing test",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
|
||||||
|
# Rule matches but test_paths is not a list, so it's skipped — no missing
|
||||||
|
assert missing == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_no_changed_files(self, tmp_path: Path) -> None:
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_test_coverage._changed_files", return_value=[]),
|
||||||
|
patch(
|
||||||
|
"devx.tools.check_test_coverage._load_rules",
|
||||||
|
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||||
|
),
|
||||||
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
assert main([]) == 0
|
||||||
|
|
||||||
|
def test_all_have_tests(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||||
|
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
|
||||||
|
patch(
|
||||||
|
"devx.tools.check_test_coverage._load_rules",
|
||||||
|
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||||
|
),
|
||||||
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
assert main([]) == 0
|
||||||
|
|
||||||
|
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
|
||||||
|
patch(
|
||||||
|
"devx.tools.check_test_coverage._load_rules",
|
||||||
|
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||||
|
),
|
||||||
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
assert main([]) == 1
|
||||||
|
|
||||||
|
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
|
||||||
|
patch(
|
||||||
|
"devx.tools.check_test_coverage._load_rules",
|
||||||
|
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
|
||||||
|
),
|
||||||
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
assert main(["--warn-only"]) == 0
|
||||||
@@ -317,9 +317,9 @@ class TestCollectKeys:
|
|||||||
assert "completed" in keys
|
assert "completed" in keys
|
||||||
assert "pending" in keys
|
assert "pending" in keys
|
||||||
|
|
||||||
def test_default_dir_includes_dynamic_keys(self) -> None:
|
def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
|
||||||
"""The default source dir should include DYNAMIC_KEYS."""
|
"""collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
|
||||||
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
|
keys = check_translations.collect_keys(tmp_path)
|
||||||
assert "completed" in keys
|
assert "completed" in keys
|
||||||
assert "pending" in keys
|
assert "pending" in keys
|
||||||
assert "in_progress" in keys
|
assert "in_progress" in keys
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ class TestClassifierConfig:
|
|||||||
assert config.user_facing_overrides == []
|
assert config.user_facing_overrides == []
|
||||||
assert config.tags == {}
|
assert config.tags == {}
|
||||||
|
|
||||||
|
def test_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None:
|
||||||
|
"""Project infrastructure patterns already in defaults are not duplicated."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n')
|
||||||
|
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||||
|
# .gitea/** should appear only once (deduplicated with defaults)
|
||||||
|
assert config.infrastructure.count(".gitea/**") == 1
|
||||||
|
assert "scripts/**" in config.infrastructure
|
||||||
|
|
||||||
def test_defaults_are_empty_for_bare_constructor(self) -> None:
|
def test_defaults_are_empty_for_bare_constructor(self) -> None:
|
||||||
"""ClassifierConfig() without from_pyproject has empty lists."""
|
"""ClassifierConfig() without from_pyproject has empty lists."""
|
||||||
config = ClassifierConfig()
|
config = ClassifierConfig()
|
||||||
@@ -515,6 +524,27 @@ class TestMain:
|
|||||||
assert "Ansible files" in result.output
|
assert "Ansible files" in result.output
|
||||||
assert "ansible/tasks/main.yml" in result.output
|
assert "ansible/tasks/main.yml" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
@patch("devx.ci.classify_changes.get_changed_files")
|
||||||
|
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||||
|
def test_default_mode_skips_empty_tag(
|
||||||
|
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Tags with no matching files are skipped in default mode output."""
|
||||||
|
mock_changes.return_value = ["ansible/tasks/main.yml"]
|
||||||
|
mock_clf.return_value = ChangeClassifier(
|
||||||
|
ClassifierConfig(
|
||||||
|
infrastructure=[".gitea/**"],
|
||||||
|
tags={"ansible": ["ansible/**"], "docs": ["docs/**"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Ansible files" in result.output
|
||||||
|
# docs tag has no matching files — should not appear
|
||||||
|
assert "Docs files" not in result.output
|
||||||
|
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
+106
-25
@@ -1,12 +1,14 @@
|
|||||||
"""Unit tests for config module constants."""
|
"""Unit tests for config module constants."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from devx.config import (
|
from devx.config import (
|
||||||
CONVENTIONAL_RE,
|
CONVENTIONAL_RE,
|
||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
GITEA_API_URL,
|
GITEA_API_URL,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
REPO_OWNER,
|
|
||||||
RETRY_BACKOFF_BASE,
|
RETRY_BACKOFF_BASE,
|
||||||
RETRY_STATUS_CODES,
|
RETRY_STATUS_CODES,
|
||||||
TASK_ID_RE,
|
TASK_ID_RE,
|
||||||
@@ -20,25 +22,10 @@ class TestConfigConstants:
|
|||||||
assert "api/v1" in GITEA_API_URL
|
assert "api/v1" in GITEA_API_URL
|
||||||
assert "api/v1" in VIKUNJA_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:
|
def test_timeouts(self) -> None:
|
||||||
assert DEFAULT_TIMEOUT == 30
|
assert DEFAULT_TIMEOUT == 30
|
||||||
assert DEFAULT_PER_PAGE == 50
|
assert DEFAULT_PER_PAGE == 50
|
||||||
|
|
||||||
def test_owner(self) -> None:
|
|
||||||
assert REPO_OWNER == ""
|
|
||||||
|
|
||||||
def test_task_prefix(self) -> None:
|
def test_task_prefix(self) -> None:
|
||||||
assert TASK_PREFIX == "DEVX"
|
assert TASK_PREFIX == "DEVX"
|
||||||
|
|
||||||
@@ -64,28 +51,122 @@ class TestConfigConstants:
|
|||||||
assert 503 in RETRY_STATUS_CODES
|
assert 503 in RETRY_STATUS_CODES
|
||||||
assert 504 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."""
|
class TestPyprojectReading:
|
||||||
# We can't easily re-import the module, but we can verify
|
"""Test that config.py reads [tool.devx] from pyproject.toml."""
|
||||||
# the constants respect env vars by checking the module source.
|
|
||||||
|
def test_pyproject_provides_values(self) -> None:
|
||||||
|
"""When pyproject.toml has [tool.devx], values are read from it."""
|
||||||
import devx.config as cfg
|
import devx.config as cfg
|
||||||
|
|
||||||
assert cfg.GITEA_API_URL # always non-empty
|
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
|
||||||
assert cfg.VIKUNJA_API_URL # always non-empty
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 8
|
||||||
|
assert cfg.REPO_OWNER == "oblachno-oss"
|
||||||
|
|
||||||
|
def test_env_overrides_pyproject(self, monkeypatch: object) -> None:
|
||||||
|
"""Env vars take priority over pyproject.toml."""
|
||||||
|
monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM")
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "CUSTOM"
|
||||||
|
assert cfg.TASK_ID_RE.search("CUSTOM-42")
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When no pyproject.toml exists, defaults are used."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||||
|
monkeypatch.delenv("DEVX_REPO_OWNER", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||||
|
assert cfg.REPO_OWNER == ""
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When pyproject.toml is invalid TOML, defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text("invalid toml {{{")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When pyproject.toml has no [tool.devx], defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 42
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""Env var override for int config takes priority over pyproject.toml."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 99
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When [tool] is not a dict, defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When [tool.devx] is not a dict, defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
|
||||||
class TestTaskPrefixOverride:
|
class TestTaskPrefixOverride:
|
||||||
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
||||||
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
||||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
||||||
import importlib
|
|
||||||
|
|
||||||
import devx.config as cfg
|
import devx.config as cfg
|
||||||
|
|
||||||
importlib.reload(cfg)
|
importlib.reload(cfg)
|
||||||
assert cfg.TASK_PREFIX == "INFRA"
|
assert cfg.TASK_PREFIX == "INFRA"
|
||||||
assert cfg.TASK_ID_RE.search("INFRA-42")
|
assert cfg.TASK_ID_RE.search("INFRA-42")
|
||||||
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
||||||
# Restore
|
|
||||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
importlib.reload(cfg)
|
importlib.reload(cfg)
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Unit tests for devx.tools.create_pr."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import click
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.create_pr import (
|
||||||
|
cli,
|
||||||
|
create_pr,
|
||||||
|
extract_task_id,
|
||||||
|
find_existing_pr,
|
||||||
|
get_repo_name,
|
||||||
|
get_vikunja_task_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTaskId:
|
||||||
|
def test_valid(self) -> None:
|
||||||
|
assert extract_task_id("DEVX-42-fix") == "DEVX-42"
|
||||||
|
|
||||||
|
def test_invalid(self) -> None:
|
||||||
|
assert extract_task_id("feature") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRepoName:
|
||||||
|
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
|
||||||
|
def test_from_env(self) -> None:
|
||||||
|
assert get_repo_name() == "infra"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||||
|
def test_from_github(self) -> None:
|
||||||
|
assert get_repo_name() == "infra"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_missing_raises(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="Repository name"):
|
||||||
|
get_repo_name()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetVikunjaTaskTitle:
|
||||||
|
@patch("devx.tools.create_pr.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
assert get_vikunja_task_title("DEVX-42") == "Add feature"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_no_token(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"):
|
||||||
|
get_vikunja_task_title("DEVX-42")
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = []
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with pytest.raises(click.ClickException, match="Could not find"):
|
||||||
|
get_vikunja_task_title("DEVX-42")
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
from devx.config import DEFAULT_PER_PAGE
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||||
|
page2 = [{"identifier": "OTHER-99"}]
|
||||||
|
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with pytest.raises(click.ClickException, match="Could not find"):
|
||||||
|
get_vikunja_task_title("DEVX-42")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindExistingPr:
|
||||||
|
def test_found(self) -> None:
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}]
|
||||||
|
result = find_existing_pr(client, "DEVX-42-fix")
|
||||||
|
assert result is not None
|
||||||
|
assert result["number"] == 10
|
||||||
|
|
||||||
|
def test_not_found(self) -> None:
|
||||||
|
client = MagicMock()
|
||||||
|
client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}]
|
||||||
|
result = find_existing_pr(client, "DEVX-42-fix")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatePr:
|
||||||
|
@patch("devx.tools.create_pr.GiteaClient")
|
||||||
|
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||||
|
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
|
||||||
|
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||||
|
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
|
||||||
|
mock_gitea.return_value = mock_client
|
||||||
|
result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo")
|
||||||
|
assert result["number"] == 15
|
||||||
|
mock_client.create_pr.assert_called_once_with(
|
||||||
|
title="DEVX-42: Add feature",
|
||||||
|
head="DEVX-42-fix",
|
||||||
|
base="master",
|
||||||
|
body="body",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.GiteaClient")
|
||||||
|
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||||
|
@patch("devx.tools.create_pr.find_existing_pr")
|
||||||
|
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||||
|
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
|
||||||
|
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_gitea.return_value = mock_client
|
||||||
|
result = create_pr("DEVX-42-fix", "master", "", "owner", "repo")
|
||||||
|
assert result["number"] == 10
|
||||||
|
mock_client.create_pr.assert_not_called()
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_no_repo_token(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
||||||
|
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||||
|
def test_no_task_id_in_branch(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="does not contain a task ID"):
|
||||||
|
create_pr("feature-branch", "master", "", "owner", "repo")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
@patch("devx.tools.create_pr.create_pr")
|
||||||
|
@patch("devx.tools.create_pr.subprocess.run")
|
||||||
|
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||||
|
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||||
|
def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
|
||||||
|
mock_create.return_value = {"number": 1}
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.create_pr")
|
||||||
|
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||||
|
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||||
|
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
|
||||||
|
mock_create.return_value = {"number": 1}
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.create_pr")
|
||||||
|
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||||
|
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||||
|
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
|
||||||
|
mock_create.return_value = {"number": 1}
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_create.assert_called_once()
|
||||||
|
assert mock_create.call_args.args[2] == "PR body text"
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.REPO_OWNER", "")
|
||||||
|
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||||
|
def test_missing_owner(self, mock_repo: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "owner" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.create_pr")
|
||||||
|
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||||
|
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
|
||||||
|
mock_create.return_value = {"number": 1}
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo")
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.subprocess.run")
|
||||||
|
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
|
||||||
|
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
|
||||||
|
def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Could not detect" in result.output
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Unit tests for devx.tools.create_task."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.create_task import cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateTaskCli:
|
||||||
|
@patch("devx.tools.create_task.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_success(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.create_task.return_value = {"identifier": "DEVX-60", "id": 60}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--title", "Add feature X"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "DEVX-60" in result.output
|
||||||
|
mock_client.create_task.assert_called_once()
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_missing_token(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--title", "Add feature X"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "VIKUNJA_TOKEN" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.create_task.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_with_description(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.create_task.return_value = {"identifier": "DEVX-61", "id": 61}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--title", "Add feature Y", "--description", "<p>desc</p>"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_client.create_task.call_args
|
||||||
|
assert call_args.args[1] == "Add feature Y"
|
||||||
|
assert call_args.args[2] == "<p>desc</p>"
|
||||||
|
|
||||||
|
@patch("devx.tools.create_task.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_description_from_stdin(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.create_task.return_value = {"identifier": "DEVX-62", "id": 62}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--title", "Add feature Z", "--description", "-"],
|
||||||
|
input="<p>stdin desc</p>",
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_client.create_task.assert_called_once()
|
||||||
|
call_args = mock_client.create_task.call_args
|
||||||
|
assert call_args.args[2] == "<p>stdin desc</p>"
|
||||||
|
|
||||||
|
@patch("devx.tools.create_task.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_custom_project_id(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.create_task.return_value = {"identifier": "GRM-10", "id": 10}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--title", "Task", "--project-id", "3"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_client.create_task.assert_called_once_with(3, "Task", "")
|
||||||
|
|
||||||
|
@patch("devx.tools.create_task.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_no_identifier_in_response(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.create_task.return_value = {"id": 99}
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--title", "Task"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "id=99" in result.output
|
||||||
@@ -237,3 +237,15 @@ class TestMain:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--github-output"])
|
result = runner.invoke(main, ["--github-output"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
@patch("devx.ci.discover_runners.get_runner_count", return_value=2)
|
||||||
|
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
|
||||||
|
"""When --owner and --repo are provided, env vars are not used."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_count.assert_called_once()
|
||||||
|
# Verify owner/repo passed through
|
||||||
|
args, kwargs = mock_count.call_args
|
||||||
|
assert "myorg" in args
|
||||||
|
assert "myrepo" in args
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
from devx.ci.distribute_files import (
|
from devx.ci.distribute_files import (
|
||||||
DEFAULT_MAX_RUNNERS,
|
DEFAULT_MAX_RUNNERS,
|
||||||
|
_file_weight,
|
||||||
discover_files,
|
discover_files,
|
||||||
distribute,
|
distribute,
|
||||||
files_for_runner,
|
files_for_runner,
|
||||||
@@ -169,3 +170,46 @@ def test_main_module_block() -> None:
|
|||||||
import devx.ci.distribute_files as mod
|
import devx.ci.distribute_files as mod
|
||||||
|
|
||||||
assert hasattr(mod, "main")
|
assert hasattr(mod, "main")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileWeight:
|
||||||
|
def test_weight_based_on_size(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_big.py"
|
||||||
|
f.write_text("x" * 5000)
|
||||||
|
assert _file_weight(str(f)) == 5000
|
||||||
|
|
||||||
|
def test_min_weight_is_1(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "empty.py"
|
||||||
|
f.write_text("")
|
||||||
|
assert _file_weight(str(f)) == 1
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_1(self) -> None:
|
||||||
|
assert _file_weight("/nonexistent/file.py") == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistributeLpt:
|
||||||
|
def test_large_files_on_different_runners(self, tmp_path: Path) -> None:
|
||||||
|
"""Two large files should go to different runners."""
|
||||||
|
big1 = tmp_path / "test_big1.py"
|
||||||
|
big2 = tmp_path / "test_big2.py"
|
||||||
|
small1 = tmp_path / "test_small1.py"
|
||||||
|
small2 = tmp_path / "test_small2.py"
|
||||||
|
big1.write_text("x" * 10000)
|
||||||
|
big2.write_text("x" * 10000)
|
||||||
|
small1.write_text("x")
|
||||||
|
small2.write_text("x")
|
||||||
|
files = [str(big1), str(big2), str(small1), str(small2)]
|
||||||
|
groups = distribute(files, 2)
|
||||||
|
runner_0 = groups[0]
|
||||||
|
runner_1 = groups[1]
|
||||||
|
# Big files should be on different runners
|
||||||
|
assert not (str(big1) in runner_0 and str(big2) in runner_0)
|
||||||
|
assert not (str(big1) in runner_1 and str(big2) in runner_1)
|
||||||
|
|
||||||
|
def test_all_files_preserved(self, tmp_path: Path) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
(tmp_path / f"test_{i}.py").write_text(f"content {i}" * (i + 1))
|
||||||
|
files = [str(tmp_path / f"test_{i}.py") for i in range(5)]
|
||||||
|
groups = distribute(files, 3)
|
||||||
|
flat = sorted(f for group in groups for f in group)
|
||||||
|
assert flat == sorted(files)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from devx.molecule.distribute_molecule import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
MultiRoleTestPair,
|
MultiRoleTestPair,
|
||||||
TestPair,
|
TestPair,
|
||||||
|
_load_molecule_weights,
|
||||||
|
_lpt_distribute,
|
||||||
|
_scenario_weight,
|
||||||
build_multi_role_pairs,
|
build_multi_role_pairs,
|
||||||
build_pairs,
|
build_pairs,
|
||||||
cli,
|
cli,
|
||||||
@@ -477,3 +480,159 @@ class TestCliMultiRole:
|
|||||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
|
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "out of range" in result.output
|
assert "out of range" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestScenarioWeight:
|
||||||
|
def test_default_weight_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Without pyproject.toml, all scenarios get the default weight."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
scenario_w, role_w = _load_molecule_weights()
|
||||||
|
assert scenario_w == {}
|
||||||
|
assert role_w == {}
|
||||||
|
assert _scenario_weight("unknown-scenario") == 3
|
||||||
|
|
||||||
|
def test_load_weights_from_pyproject(self, tmp_path: Path) -> None:
|
||||||
|
"""Weights are loaded from [tool.devx.molecule.weights] in pyproject.toml."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text(
|
||||||
|
"[tool.devx.molecule.weights]\n"
|
||||||
|
'"nextcloud" = 15\n'
|
||||||
|
'"default" = 3\n'
|
||||||
|
'"binary" = 2\n'
|
||||||
|
'"app_container/customer-apps" = 11\n'
|
||||||
|
'"restore/default" = 11\n'
|
||||||
|
)
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
assert scenario_w == {"nextcloud": 15, "default": 3, "binary": 2}
|
||||||
|
assert role_w == {("app_container", "customer-apps"): 11, ("restore", "default"): 11}
|
||||||
|
|
||||||
|
def test_role_specific_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Role-specific weights take priority over scenario-name-only weights."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text(
|
||||||
|
'[tool.devx.molecule.weights]\n"default" = 3\n"docker_base/default" = 8\n"restore/default" = 11\n'
|
||||||
|
)
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
|
||||||
|
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
|
||||||
|
assert _scenario_weight("default", "docker_base") == 8
|
||||||
|
assert _scenario_weight("default", "restore") == 11
|
||||||
|
assert _scenario_weight("default", "app_container") == 3
|
||||||
|
|
||||||
|
def test_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Weight keys are matched case-insensitively."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
|
||||||
|
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
|
||||||
|
assert _scenario_weight("NextCloud") == 15
|
||||||
|
assert _scenario_weight("NEXTCLOUD") == 15
|
||||||
|
|
||||||
|
def test_substring_match(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Scenario-name weights use substring matching."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
|
||||||
|
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
|
||||||
|
assert _scenario_weight("nextcloud-with-redis") == 15
|
||||||
|
|
||||||
|
def test_no_pyproject_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing pyproject.toml returns empty weight dicts."""
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(tmp_path / "nonexistent.toml"))
|
||||||
|
assert scenario_w == {}
|
||||||
|
assert role_w == {}
|
||||||
|
|
||||||
|
def test_invalid_weights_ignored(self, tmp_path: Path) -> None:
|
||||||
|
"""Non-integer weight values are silently ignored."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.molecule.weights]\n"good" = 5\n"bad" = "not an int"\n')
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
assert scenario_w == {"good": 5}
|
||||||
|
assert role_w == {}
|
||||||
|
|
||||||
|
def test_malformed_toml_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
"""Malformed TOML returns empty weight dicts."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("this is not valid toml = = =")
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
assert scenario_w == {}
|
||||||
|
assert role_w == {}
|
||||||
|
|
||||||
|
def test_non_dict_weights_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
"""If [tool.devx.molecule.weights] is not a table, returns empty dicts."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.molecule]\nweights = "not a table"\n')
|
||||||
|
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||||
|
assert scenario_w == {}
|
||||||
|
assert role_w == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLptDistribute:
|
||||||
|
def test_equal_weights_produce_even_split(self) -> None:
|
||||||
|
items = list(range(6))
|
||||||
|
weights = [3, 3, 3, 3, 3, 3]
|
||||||
|
groups = _lpt_distribute(items, weights, 3)
|
||||||
|
assert all(len(g) == 2 for g in groups)
|
||||||
|
|
||||||
|
def test_heavy_items_on_different_runners(self) -> None:
|
||||||
|
"""Two heavy items should go to different runners."""
|
||||||
|
items = ["heavy-a", "heavy-b", "light-1", "light-2"]
|
||||||
|
weights = [10, 10, 1, 1]
|
||||||
|
groups = _lpt_distribute(items, weights, 2)
|
||||||
|
# Heavy items should be on different runners
|
||||||
|
flat = [item for group in groups for item in group]
|
||||||
|
assert "heavy-a" in flat
|
||||||
|
assert "heavy-b" in flat
|
||||||
|
runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g)
|
||||||
|
runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g)
|
||||||
|
assert runner_a != runner_b
|
||||||
|
|
||||||
|
def test_load_balance_with_varying_weights(self) -> None:
|
||||||
|
"""LPT should produce better load balance than round-robin."""
|
||||||
|
items = list(range(7))
|
||||||
|
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
|
||||||
|
weights = [10, 10, 7, 7, 3, 3, 3]
|
||||||
|
groups = _lpt_distribute(items, weights, 3)
|
||||||
|
loads = [sum(weights[i] for i in g) for g in groups]
|
||||||
|
# LPT should produce loads close to total/3 = 43/3 ≈ 14.3
|
||||||
|
# Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible)
|
||||||
|
assert max(loads) - min(loads) <= 10 # Reasonably balanced
|
||||||
|
|
||||||
|
def test_more_runners_than_items(self) -> None:
|
||||||
|
items = ["a"]
|
||||||
|
weights = [5]
|
||||||
|
groups = _lpt_distribute(items, weights, 5)
|
||||||
|
assert len(groups) == 5
|
||||||
|
assert len(groups[0]) == 1
|
||||||
|
assert all(len(g) == 0 for g in groups[1:])
|
||||||
|
|
||||||
|
def test_empty_items(self) -> None:
|
||||||
|
groups = _lpt_distribute([], [], 3)
|
||||||
|
assert groups == [[], [], []]
|
||||||
|
|
||||||
|
def test_preserves_all_items(self) -> None:
|
||||||
|
items = ["a", "b", "c", "d", "e"]
|
||||||
|
weights = [5, 3, 8, 1, 2]
|
||||||
|
groups = _lpt_distribute(items, weights, 3)
|
||||||
|
flat = sorted(item for group in groups for item in group)
|
||||||
|
assert flat == sorted(items)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistributeLpt:
|
||||||
|
def test_nextcloud_on_separate_runners(self) -> None:
|
||||||
|
"""Two nextcloud scenarios should go to different runners."""
|
||||||
|
pairs = [
|
||||||
|
TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
TestPair("binary", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
TestPair("default", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
]
|
||||||
|
groups = distribute(pairs, 2)
|
||||||
|
# Both nextcloud scenarios (weight 10) should be on different runners
|
||||||
|
runner_0 = [p.scenario for p in groups[0]]
|
||||||
|
runner_1 = [p.scenario for p in groups[1]]
|
||||||
|
# nextcloud and nextcloud-backup should NOT be on the same runner
|
||||||
|
assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0)
|
||||||
|
assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ class TestExtractCliCommands:
|
|||||||
commands = extract_cli_commands()
|
commands = extract_cli_commands()
|
||||||
assert "my_command" in commands
|
assert "my_command" in commands
|
||||||
|
|
||||||
|
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""When a command decorator has no name and no following def, it is skipped."""
|
||||||
|
from devx.ci import doc_coverage
|
||||||
|
|
||||||
|
fake_cli = tmp_path / "cli.py"
|
||||||
|
# The last @cli.command() has no explicit name and no def statement after it
|
||||||
|
fake_cli.write_text(
|
||||||
|
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||||
|
commands = extract_cli_commands()
|
||||||
|
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||||
|
assert "real_cmd" in commands
|
||||||
|
assert "pass" not in commands
|
||||||
|
|
||||||
|
|
||||||
class TestCheckCommandDocumented:
|
class TestCheckCommandDocumented:
|
||||||
def test_finds_command_in_heading(self) -> None:
|
def test_finds_command_in_heading(self) -> None:
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ class TestDetectCoverageTarget:
|
|||||||
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
assert detect_coverage_target(tmp_path) is None
|
assert detect_coverage_target(tmp_path) is None
|
||||||
|
|
||||||
|
def test_pyproject_without_cov_falls_back_to_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""When pyproject exists but has no --cov=, falls back to package name."""
|
||||||
|
src = tmp_path / "src"
|
||||||
|
pkg = src / "mypkg"
|
||||||
|
pkg.mkdir(parents=True)
|
||||||
|
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\naddopts = "-ra"\n')
|
||||||
|
assert detect_coverage_target(tmp_path) == "src/mypkg"
|
||||||
|
|
||||||
|
|
||||||
class TestDetectTestpaths:
|
class TestDetectTestpaths:
|
||||||
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
@@ -112,6 +121,14 @@ class TestDetectTestpaths:
|
|||||||
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
|
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
|
||||||
assert detect_testpaths(tmp_path) == ["tests"]
|
assert detect_testpaths(tmp_path) == ["tests"]
|
||||||
|
|
||||||
|
def test_all_paths_nonexistent_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""When all testpaths are non-existent, falls back to tests/ directory."""
|
||||||
|
(tmp_path / "tests").mkdir()
|
||||||
|
(tmp_path / "pyproject.toml").write_text(
|
||||||
|
'[tool.pytest.ini_options]\ntestpaths = ["nonexistent1", "nonexistent2"]\n'
|
||||||
|
)
|
||||||
|
assert detect_testpaths(tmp_path) == ["tests"]
|
||||||
|
|
||||||
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
(tmp_path / "tests").mkdir()
|
(tmp_path / "tests").mkdir()
|
||||||
assert detect_testpaths(tmp_path) == ["tests"]
|
assert detect_testpaths(tmp_path) == ["tests"]
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ class TestTeaCLIRun:
|
|||||||
with pytest.raises(TeaCLIError, match="auth error"):
|
with pytest.raises(TeaCLIError, match="auth error"):
|
||||||
cli._run(["labels", "list"])
|
cli._run(["labels", "list"])
|
||||||
|
|
||||||
|
def test_run_tea_not_found_raises_tea_error(self) -> None:
|
||||||
|
cli = TeaCLI(tea_bin="tea")
|
||||||
|
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
||||||
|
with pytest.raises(TeaCLIError, match="tea binary not found"):
|
||||||
|
cli._run(["labels", "list"])
|
||||||
|
|
||||||
def test_run_includes_json_flag(self) -> None:
|
def test_run_includes_json_flag(self) -> None:
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
cli = TeaCLI(tea_bin="/fake/tea")
|
||||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
||||||
@@ -350,6 +356,6 @@ class TestListBranches:
|
|||||||
class TestWhoami:
|
class TestWhoami:
|
||||||
def test_whoami(self) -> None:
|
def test_whoami(self) -> None:
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
cli = TeaCLI(tea_bin="/fake/tea")
|
||||||
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
|
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
assert cli.whoami() == "emil"
|
assert cli.whoami() == "testuser"
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -163,7 +163,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -208,7 +208,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ class TestBuildEnvForPair:
|
|||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
|
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
|
||||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
||||||
|
|
||||||
|
def test_preserves_existing_molecule_home(self) -> None:
|
||||||
|
"""When MOLECULE_HOME is already set, it is not overridden."""
|
||||||
|
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
|
||||||
|
assert env["MOLECULE_HOME"] == "/custom/home"
|
||||||
|
|
||||||
|
|
||||||
class TestPollForOtherFailures:
|
class TestPollForOtherFailures:
|
||||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
||||||
@@ -297,7 +302,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -333,7 +338,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -380,7 +385,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
mock_killpg.side_effect = ProcessLookupError("no such process")
|
mock_killpg.side_effect = ProcessLookupError("no such process")
|
||||||
@@ -427,7 +432,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
||||||
|
|||||||
@@ -208,3 +208,14 @@ class TestMain:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--github-output"])
|
result = runner.invoke(main, ["--github-output"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
@patch("devx.molecule.discover_runners.get_runner_count", return_value=2)
|
||||||
|
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
|
||||||
|
"""When --owner and --repo are provided, env vars are not used."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_count.assert_called_once()
|
||||||
|
args, kwargs = mock_count.call_args
|
||||||
|
assert "myorg" in args
|
||||||
|
assert "myrepo" in args
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ class TestCheckArchitectureCompliance:
|
|||||||
assert result.has_issues
|
assert result.has_issues
|
||||||
assert "os.system" in result.issues[0]["body"]
|
assert "os.system" in result.issues[0]["body"]
|
||||||
|
|
||||||
|
def test_malformed_hunk_header_no_line_number(self) -> None:
|
||||||
|
"""A @@ header without a +N line number is handled gracefully."""
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [
|
||||||
|
{
|
||||||
|
"filename": "src/devx/cli.py",
|
||||||
|
"patch": "@@ -1,2 @@\n+ subprocess.run(['ls'])\n",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
check_architecture_compliance(files, result)
|
||||||
|
assert result.has_issues
|
||||||
|
|
||||||
|
|
||||||
class TestCheckBestPractices:
|
class TestCheckBestPractices:
|
||||||
def test_print_triggers_warning(self) -> None:
|
def test_print_triggers_warning(self) -> None:
|
||||||
@@ -190,6 +202,19 @@ class TestCheckBestPractices:
|
|||||||
check_best_practices(files, result)
|
check_best_practices(files, result)
|
||||||
assert not result.has_issues
|
assert not result.has_issues
|
||||||
|
|
||||||
|
def test_malformed_hunk_header_no_line_number(self) -> None:
|
||||||
|
"""A @@ header without a +N line number is handled gracefully."""
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [
|
||||||
|
{
|
||||||
|
"filename": "src/devx/cli.py",
|
||||||
|
"patch": "@@ -1,2 @@\n+ print('hello')\n",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
check_best_practices(files, result)
|
||||||
|
assert result.has_issues
|
||||||
|
assert "print()" in result.issues[0]["body"]
|
||||||
|
|
||||||
|
|
||||||
class TestCheckSecurity:
|
class TestCheckSecurity:
|
||||||
def test_hardcoded_secret_triggers_error(self) -> None:
|
def test_hardcoded_secret_triggers_error(self) -> None:
|
||||||
@@ -239,6 +264,19 @@ class TestCheckSecurity:
|
|||||||
check_security(files, result)
|
check_security(files, result)
|
||||||
assert not result.has_issues
|
assert not result.has_issues
|
||||||
|
|
||||||
|
def test_malformed_hunk_header_no_line_number(self) -> None:
|
||||||
|
"""A @@ header without a +N line number is handled gracefully."""
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [
|
||||||
|
{
|
||||||
|
"filename": "src/devx/config.py",
|
||||||
|
"patch": "@@ -1,2 @@\n+ token = 'abc123secrettoken456'\n",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
check_security(files, result)
|
||||||
|
assert result.has_issues
|
||||||
|
assert "secret" in result.issues[0]["body"].lower()
|
||||||
|
|
||||||
|
|
||||||
class TestCheckI18n:
|
class TestCheckI18n:
|
||||||
def test_raw_string_in_echo_triggers_warning(self) -> None:
|
def test_raw_string_in_echo_triggers_warning(self) -> None:
|
||||||
@@ -295,6 +333,14 @@ class TestCheckI18n:
|
|||||||
check_i18n(files, result)
|
check_i18n(files, result)
|
||||||
assert any("i18n: OK" in s for s in result.summary)
|
assert any("i18n: OK" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_malformed_hunk_header_no_line_number(self) -> None:
|
||||||
|
"""A @@ header without a +N line number is handled gracefully."""
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\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)
|
||||||
|
|
||||||
|
|
||||||
class TestCheckResourceManagement:
|
class TestCheckResourceManagement:
|
||||||
def test_open_without_with_triggers_warning(self) -> None:
|
def test_open_without_with_triggers_warning(self) -> None:
|
||||||
@@ -366,6 +412,14 @@ class TestCheckResourceManagement:
|
|||||||
check_resource_management(files, result)
|
check_resource_management(files, result)
|
||||||
assert any("Resource management: OK" in s for s in result.summary)
|
assert any("Resource management: OK" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_malformed_hunk_header_no_line_number(self) -> None:
|
||||||
|
"""A @@ header without a +N line number is handled gracefully."""
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\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)
|
||||||
|
|
||||||
|
|
||||||
class TestCheckFunctionLength:
|
class TestCheckFunctionLength:
|
||||||
def test_long_function_triggers_warning(self) -> None:
|
def test_long_function_triggers_warning(self) -> None:
|
||||||
@@ -429,6 +483,13 @@ class TestCheckFunctionLength:
|
|||||||
assert result.has_issues
|
assert result.has_issues
|
||||||
assert "foo" in result.issues[0]["body"]
|
assert "foo" in result.issues[0]["body"]
|
||||||
|
|
||||||
|
def test_malformed_hunk_header_no_line_number(self) -> None:
|
||||||
|
"""A @@ header without a +N line number is handled gracefully."""
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "src/devx/cli.py", "patch": "@@ -1,2 @@\n+def foo():\n+ pass\n"}]
|
||||||
|
check_function_length(files, result)
|
||||||
|
assert not result.has_issues
|
||||||
|
|
||||||
|
|
||||||
class TestCheckDocumentation:
|
class TestCheckDocumentation:
|
||||||
def test_src_changes_without_docs_warns(self) -> None:
|
def test_src_changes_without_docs_warns(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Unit tests for devx.tools.pre_push_check."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import click
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.pre_push_check import (
|
||||||
|
cli,
|
||||||
|
extract_task_id,
|
||||||
|
get_current_branch,
|
||||||
|
task_exists,
|
||||||
|
validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTaskId:
|
||||||
|
def test_valid_branch(self) -> None:
|
||||||
|
assert extract_task_id("DEVX-42-fix-bug") == "DEVX-42"
|
||||||
|
|
||||||
|
def test_no_task_id(self) -> None:
|
||||||
|
assert extract_task_id("feature-branch") == ""
|
||||||
|
|
||||||
|
def test_empty_branch(self) -> None:
|
||||||
|
assert extract_task_id("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentBranch:
|
||||||
|
@patch("devx.tools.pre_push_check.subprocess.run")
|
||||||
|
def test_success(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
|
||||||
|
assert get_current_branch() == "DEVX-42-fix"
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.subprocess.run")
|
||||||
|
def test_failure(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||||
|
assert get_current_branch() == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskExists:
|
||||||
|
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
assert task_exists("DEVX-42") is True
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
assert task_exists("DEVX-42") is False
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_no_token(self) -> None:
|
||||||
|
assert task_exists("DEVX-42") is False
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_pagination(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
# First page: full page (50 items, none matching), second page: match
|
||||||
|
page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)]
|
||||||
|
page2 = [{"identifier": "DEVX-42"}]
|
||||||
|
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
assert task_exists("DEVX-42") is True
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_empty_project(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_project_tasks.return_value = []
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
assert task_exists("DEVX-42") is False
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
from devx.config import DEFAULT_PER_PAGE
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||||
|
page2 = [{"identifier": "OTHER-99"}]
|
||||||
|
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
assert task_exists("DEVX-42") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate:
|
||||||
|
def test_master_branch_skips(self) -> None:
|
||||||
|
validate("master")
|
||||||
|
|
||||||
|
def test_main_branch_skips(self) -> None:
|
||||||
|
validate("main")
|
||||||
|
|
||||||
|
def test_empty_branch_skips(self) -> None:
|
||||||
|
validate("")
|
||||||
|
|
||||||
|
def test_no_task_id_raises(self) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="does not contain a task ID"):
|
||||||
|
validate("feature-branch")
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_no_token_warns(self) -> None:
|
||||||
|
validate("DEVX-42-fix-bug")
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_task_exists_passes(self, mock_exists: MagicMock) -> None:
|
||||||
|
validate("DEVX-42-fix-bug")
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.task_exists", return_value=False)
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_task_not_found_raises(self, mock_exists: MagicMock) -> None:
|
||||||
|
with pytest.raises(click.ClickException, match="not found"):
|
||||||
|
validate("DEVX-42-fix-bug")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
@patch("devx.tools.pre_push_check.get_current_branch", return_value="master")
|
||||||
|
def test_auto_detect_master(self, mock_branch: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
|
||||||
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
|
def test_explicit_branch(self, mock_exists: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "passed" in result.output
|
||||||
@@ -156,6 +156,13 @@ class TestDefaultGiteaRegistryUrl:
|
|||||||
url = _default_gitea_registry_url()
|
url = _default_gitea_registry_url()
|
||||||
assert "oblachno-oss" in url
|
assert "oblachno-oss" in url
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
||||||
|
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/")
|
||||||
|
def test_no_api_suffix(self) -> None:
|
||||||
|
"""URL without /api/v1 or /api suffix is used as-is."""
|
||||||
|
url = _default_gitea_registry_url()
|
||||||
|
assert url == "https://git.example.com/api/packages/myorg/pypi"
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||||
@@ -391,10 +398,16 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
def test_create_release_already_exists_is_idempotent(
|
def test_create_release_already_exists_is_idempotent(
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
self,
|
||||||
|
mock_build: MagicMock,
|
||||||
|
mock_publish: MagicMock,
|
||||||
|
mock_gitea_pub: MagicMock,
|
||||||
|
mock_tea_cls: MagicMock,
|
||||||
|
mock_notes: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""If create_release fails with 'already exists', treat as success."""
|
"""If create_release fails with 'already exists', treat as success."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
@@ -409,10 +422,16 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
def test_create_release_other_error_raises(
|
def test_create_release_other_error_raises(
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
self,
|
||||||
|
mock_build: MagicMock,
|
||||||
|
mock_publish: MagicMock,
|
||||||
|
mock_gitea_pub: MagicMock,
|
||||||
|
mock_tea_cls: MagicMock,
|
||||||
|
mock_notes: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""If create_release fails with a non-'already exists' error, raise."""
|
"""If create_release fails with a non-'already exists' error, raise."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Unit tests for scripts/ci/release.py."""
|
"""Unit tests for scripts/ci/release.py."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -508,6 +510,30 @@ class TestVerifyAlignment:
|
|||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
assert verify_alignment() == 1
|
assert verify_alignment() == 1
|
||||||
|
|
||||||
|
@patch("devx.ci.release.run_cmd")
|
||||||
|
@patch("devx.ci.release.get_changelog_versions")
|
||||||
|
@patch("devx.ci.release.get_init_version")
|
||||||
|
@patch("devx.ci.release.verify_tag_consistency")
|
||||||
|
@patch("devx.ci.release.get_all_tags")
|
||||||
|
@patch("devx.ci.release.get_latest_tag")
|
||||||
|
def test_no_latest_tag_skips_changelog_tag_check(
|
||||||
|
self,
|
||||||
|
mock_lt: MagicMock,
|
||||||
|
mock_tags: MagicMock,
|
||||||
|
mock_vtc: MagicMock,
|
||||||
|
mock_iv: MagicMock,
|
||||||
|
mock_cv: MagicMock,
|
||||||
|
mock_run_cmd: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""When there is no latest tag, the CHANGELOG/tag match check is skipped."""
|
||||||
|
mock_lt.return_value = None # no tags
|
||||||
|
mock_tags.return_value = []
|
||||||
|
mock_vtc.return_value = []
|
||||||
|
mock_iv.return_value = "0.4.4"
|
||||||
|
mock_cv.return_value = ["0.4.4"] # changelog has versions but no tag to compare
|
||||||
|
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
assert verify_alignment() == 0
|
||||||
|
|
||||||
@patch("devx.ci.release.run_cmd")
|
@patch("devx.ci.release.run_cmd")
|
||||||
@patch("devx.ci.release.get_changelog_versions")
|
@patch("devx.ci.release.get_changelog_versions")
|
||||||
@patch("devx.ci.release.get_init_version")
|
@patch("devx.ci.release.get_init_version")
|
||||||
@@ -756,6 +782,16 @@ class TestUpdateChangelog:
|
|||||||
assert "# Changelog" not in content
|
assert "# Changelog" not in content
|
||||||
assert "## [0.2.0]" in content
|
assert "## [0.2.0]" in content
|
||||||
|
|
||||||
|
def test_no_version_section_in_changelog(self, tmp_path, monkeypatch) -> None:
|
||||||
|
"""Changelog input without any ## [ version section is inserted as-is."""
|
||||||
|
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))
|
||||||
|
# No ## [ section in the cliff output — should not be stripped
|
||||||
|
update_changelog("Some raw text without version header")
|
||||||
|
content = changelog_file.read_text()
|
||||||
|
assert "Some raw text without version header" in content
|
||||||
|
|
||||||
|
|
||||||
class TestCommitReleaseChanges:
|
class TestCommitReleaseChanges:
|
||||||
@patch("devx.ci.release.run_cmd")
|
@patch("devx.ci.release.run_cmd")
|
||||||
@@ -781,11 +817,23 @@ class TestCommitReleaseChanges:
|
|||||||
class TestCreateAndPushTag:
|
class TestCreateAndPushTag:
|
||||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||||
@patch("devx.ci.release.run_cmd")
|
@patch("devx.ci.release.run_cmd")
|
||||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, tmp_path: Path) -> None:
|
||||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
github_output = tmp_path / "output.txt"
|
||||||
|
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}):
|
||||||
|
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
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", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||||
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
|
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
|
||||||
|
assert github_output.read_text() == "tag=v0.2.0\n"
|
||||||
|
|
||||||
|
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||||
|
@patch("devx.ci.release.run_cmd")
|
||||||
|
def test_no_github_output_skips_write(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||||
|
# Should still create tag, just not write GITHUB_OUTPUT
|
||||||
|
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
|
||||||
|
|
||||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||||
@patch("devx.ci.release.run_cmd")
|
@patch("devx.ci.release.run_cmd")
|
||||||
|
|||||||
@@ -186,6 +186,12 @@ class TestVerify:
|
|||||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
|
||||||
_verify(".venv/bin") # Should not raise
|
_verify(".venv/bin") # Should not raise
|
||||||
|
|
||||||
|
@patch("devx.tools.setup.subprocess.run")
|
||||||
|
def test_verify_handles_nonzero_returncode(self, mock_run: MagicMock) -> None:
|
||||||
|
"""When a tool returns non-zero, it is skipped without raising."""
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||||
|
_verify(".venv/bin") # Should not raise
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@patch("devx.tools.setup._configure_tea_login")
|
@patch("devx.tools.setup._configure_tea_login")
|
||||||
@@ -304,6 +310,28 @@ class TestMain:
|
|||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Bin directory not found" in result.output
|
assert "Bin directory not found" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.setup._verify")
|
||||||
|
@patch("devx.tools.setup._configure_tea_login")
|
||||||
|
@patch("devx.tools.setup._install_pre_commit_hooks")
|
||||||
|
@patch("devx.tools.setup._install_ansible_collections")
|
||||||
|
@patch("devx.tools.setup._install_python_deps")
|
||||||
|
def test_main_skip_install(
|
||||||
|
self,
|
||||||
|
mock_install_deps: MagicMock,
|
||||||
|
mock_install_ansible: MagicMock,
|
||||||
|
mock_install_hooks: MagicMock,
|
||||||
|
mock_verify: MagicMock,
|
||||||
|
mock_tea: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--bin", str(bin_dir), "--skip-install"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_install_deps.assert_not_called()
|
||||||
|
assert "Skipping pip install" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block(tmp_path: Path) -> None:
|
def test_main_module_block(tmp_path: Path) -> None:
|
||||||
"""Test the __main__ block execution."""
|
"""Test the __main__ block execution."""
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ class TestDiagnoseSocket:
|
|||||||
_diagnose_socket()
|
_diagnose_socket()
|
||||||
mock_exists.assert_called_with(DOCKER_SOCK)
|
mock_exists.assert_called_with(DOCKER_SOCK)
|
||||||
|
|
||||||
|
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||||
|
@patch("devx.molecule.start_docker.os.stat")
|
||||||
|
@patch("devx.molecule.start_docker.subprocess.run")
|
||||||
|
def test_docker_info_no_matching_lines(
|
||||||
|
self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines."""
|
||||||
|
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
|
||||||
|
MagicMock(stdout="default\n", returncode=0, text=""),
|
||||||
|
MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""),
|
||||||
|
]
|
||||||
|
_diagnose_socket()
|
||||||
|
mock_exists.assert_called_with(DOCKER_SOCK)
|
||||||
|
|
||||||
|
|
||||||
class TestStartDockerDaemon:
|
class TestStartDockerDaemon:
|
||||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||||
|
|||||||
Reference in New Issue
Block a user