Compare commits

..
37 Commits
Author SHA1 Message Date
devx-ci-bot 59d6fa1833 release: v0.40.1 [skip ci] 2026-07-12 16:34:45 +00:00
emil d035b620e0 DEVX-127: fix: fall back to CI token when reviewer self-approval is rejected
Post-merge / detect-and-configure (push) Successful in 17s
Post-merge / release-and-maintain (push) Successful in 1m25s
2026-07-12 16:33:53 +00:00
gitea-actions-bot 5987adee64 chore: update badge URLs to commit a22225af [skip ci] 2026-07-12 01:53:50 +00:00
emil cb84dae050 DEVX-126: ci: consolidate CI and post-merge workflows
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 46s
2026-07-12 01:52:40 +00:00
gitea-actions-bot ed0dfce98b chore: update badge URLs to commit 2747061d [skip ci] 2026-07-11 23:09:11 +00:00
devx-ci-bot c244881f22 release: v0.40.0 [skip ci] 2026-07-11 23:08:23 +00:00
emil 4cde7de696 DEVX-125: feat: detect double-prefix in Vikunja task title during pre-merge validation
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 30s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / publish (push) Successful in 19s
Post-merge / badges (push) Successful in 41s
2026-07-11 23:07:45 +00:00
gitea-actions-bot d675889604 chore: update badge URLs to commit c9f25c13 [skip ci] 2026-07-09 11:54:42 +00:00
devx-ci-bot e23138e731 release: v0.39.0 [skip ci] 2026-07-09 11:53:12 +00:00
emil ef3b882e5b DEVX-124: feat: extract shared utilities from infra and grm into devx
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / vikunja (push) Successful in 44s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / release (push) Successful in 1m7s
Post-merge / publish (push) Successful in 44s
Post-merge / badges (push) Successful in 1m6s
2026-07-09 11:51:50 +00:00
gitea-actions-bot 8d9ee1ea26 chore: update badge URLs to commit 931a4a37 [skip ci] 2026-07-08 20:20:51 +00:00
emil 1497b29487 DEVX-123: ci: retrigger workflow after configuring secrets
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / release (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / vikunja (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 26s
Post-merge / badges (push) Successful in 31s
2026-07-08 20:19:44 +00:00
gitea-actions-bot cb126e83da chore: update badge URLs to commit 37543185 [skip ci] 2026-07-08 19:31:39 +00:00
devx-ci-bot 281193c741 release: v0.38.0 [skip ci] 2026-07-08 19:30:58 +00:00
emil 0228fce5b9 DEVX-123: feat: introduce role-based Gitea API token environment variables
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 18s
Post-merge / release (push) Successful in 36s
Post-merge / publish (push) Successful in 20s
Post-merge / badges (push) Successful in 35s
2026-07-08 19:30:10 +00:00
gitea-actions-bot 981d3e41cc chore: update badge URLs to commit fe187115 [skip ci] 2026-07-07 22:02:05 +00:00
devx-ci-bot 3cd2459eef release: v0.37.0 [skip ci] 2026-07-07 22:01:14 +00:00
emil ef08513bcf DEVX-122: feat: consolidate docs checks into devx-docs-check target
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / sync-wiki (push) Successful in 32s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Successful in 38s
2026-07-07 22:00:07 +00:00
gitea-actions-bot 05922eca2f chore: update badge URLs to commit bff19e05 [skip ci] 2026-07-07 15:53:21 +00:00
devx-ci-bot 05de2b0aa9 release: v0.36.2 [skip ci] 2026-07-07 15:52:35 +00:00
emil 6a463a93d2 DEVX-121: fix: GiteaClient.set_repo_variable uses PUT instead of PATCH
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 22s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 31s
Post-merge / release (push) Successful in 38s
Post-merge / publish (push) Successful in 23s
Post-merge / badges (push) Successful in 39s
2026-07-07 15:51:45 +00:00
gitea-actions-bot ad7b52c368 chore: update badge URLs to commit d9423d85 [skip ci] 2026-07-07 12:05:28 +00:00
devx-ci-bot 6b81e1a50a release: v0.36.1 [skip ci] 2026-07-07 12:04:41 +00:00
emil 443dc01b4e DEVX-120: fix: preserve .badges/ dir during git clean in push_badges
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / vikunja (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / release (push) Successful in 40s
Post-merge / publish (push) Successful in 25s
Post-merge / badges (push) Successful in 41s
2026-07-07 12:03:51 +00:00
devx-ci-bot 1d7bf7118a release: v0.36.0 [skip ci] 2026-07-07 11:58:00 +00:00
emil f98534ebe2 DEVX-119: feat: add GiteaClient repo variable methods and parallelize pytest-cov
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / vikunja (push) Successful in 23s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 23s
Post-merge / badges (push) Failing after 31s
2026-07-07 11:57:04 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c62b168b25 DEVX-116: chore: update grm package name references
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / vikunja (push) Successful in 22s
Post-merge / release (push) Successful in 23s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / badges (push) Failing after 30s
Update hardcoded path and docstring examples from
`gitea_runner_manager` to `grm` after the package rename in grm PR #203.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:51:10 +02:00
devx-ci-bot 40a94df029 release: v0.35.7 [skip ci] 2026-07-06 13:22:27 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f50c4c1e00 DEVX-118: fix: use Gitea wiki dash-marker filename convention
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 35s
Post-merge / release (push) Successful in 44s
Post-merge / publish (push) Successful in 22s
Post-merge / badges (push) Failing after 30s
Gitea appends a ".-" suffix before ".md" for wiki page titles that
contain dashes, to distinguish literal dashes from space-to-dash
conversions. For example, "Getting-Started" becomes
"Getting-Started.-.md", while "Architecture" becomes "Architecture.md".

Previously the code wrote "Getting-Started.md" which Gitea couldn't
recognize as a valid wiki page, causing verification to fail with
"page not found" for 15 of 21 pages.

Also force-push to handle concurrent CI runs that may have pushed to
the wiki repo between our clone and push.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:21:22 +02:00
devx-ci-bot bbb264efc9 release: v0.35.6 [skip ci] 2026-07-06 13:00:59 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c97b249935 DEVX-118: fix: add delay before wiki verification to avoid race condition
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / vikunja (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 39s
Post-merge / publish (push) Successful in 25s
Post-merge / badges (push) Failing after 38s
Gitea needs a few seconds to process pushed wiki commits before a
re-clone will see them. Add a 5s sleep after a successful push before
verification re-clones the wiki.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:00:02 +02:00
devx-ci-bot 32b9a53151 release: v0.35.5 [skip ci] 2026-07-06 09:46:57 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ae68df63f1 DEVX-118: fix: embed token in wiki clone URL for push auth
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 19s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 34s
Post-merge / publish (push) Successful in 19s
Post-merge / badges (push) Failing after 29s
The wiki Git push failed with "could not read Username" because the
clone URL didn't include credentials. Use token@host URL format so
both clone and push authenticate properly.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 11:46:05 +02:00
devx-ci-bot 6402f31345 release: v0.35.4 [skip ci] 2026-07-06 09:42:49 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f017fec8f5 DEVX-118: fix: configure git identity before commit in sync_wiki
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / vikunja (push) Successful in 21s
Post-merge / sync-wiki (push) Failing after 24s
Post-merge / release (push) Successful in 37s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Failing after 31s
CI environments may lack git user.email/user.name config, causing
git commit to fail with exit code 128. Set identity explicitly before
committing wiki changes.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 11:41:36 +02:00
devx-ci-bot add02273b6 release: v0.35.3 [skip ci] 2026-07-06 09:40:19 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e489fdb206 DEVX-118: fix: replace --strict with --verify for sync_wiki
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 25s
Post-merge / vikunja (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / sync-wiki (push) Failing after 37s
Post-merge / release (push) Successful in 52s
Post-merge / publish (push) Successful in 29s
Post-merge / badges (push) Failing after 38s
The rewritten sync_wiki.py removed the --strict flag. The new git-based
approach is strict by default; --verify adds post-sync page verification.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 11:36:30 +02:00
85 changed files with 4149 additions and 1395 deletions
+15 -2
View File
@@ -1,6 +1,19 @@
# Gitea API token (required for CI scripts that interact with Gitea)
# Role-based Gitea API tokens.
# Each token serves a specific role. For small teams the developer and CI
# tokens may belong to the same user, but the reviewer token MUST belong to a
# different Gitea user than the PR author so Gitea accepts approval reviews.
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
CI_GITEA_TOKEN=
# Developer token — used by local tooling: create-task, create-pr, setup, etc.
DEVELOPER_GITEA_API_TOKEN=
# CI token — used by CI workflows and scripts that do not post approvals.
# Legacy CI_GITEA_TOKEN is also accepted.
CI_GITEA_API_TOKEN=
# Reviewer token — used by the auto-merge workflow to post APPROVE reviews.
# This must be a different Gitea user from the developer/CI user.
REVIEWER_GITEA_API_TOKEN=
# Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens
+30 -28
View File
@@ -10,9 +10,11 @@ name: Build Images
# to PyPI, so the image always has the latest released version.
# - Manually via workflow_dispatch
#
# Consolidated into 2 jobs (from 3):
# build-and-push (includes release-commit detection) ──→ cleanup
#
# 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).
@@ -28,9 +30,9 @@ concurrency:
cancel-in-progress: false
jobs:
detect-type:
build-and-push:
runs-on: docker
timeout-minutes: 5
timeout-minutes: 30
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
@@ -38,7 +40,9 @@ jobs:
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-release
- name: Check if this is a release commit
id: check
env:
@@ -46,34 +50,30 @@ jobs:
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' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-release
- name: Docker registry login
if: >-
steps.check.outputs.is-release == 'false' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: |
. .venv/bin/activate
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
_TOKEN="$CI_GITEA_API_TOKEN"
[ -z "$_TOKEN" ] && _TOKEN="$DEVELOPER_GITEA_API_TOKEN"
[ -z "$_TOKEN" ] && _TOKEN="$CI_GITEA_TOKEN"
if [ -z "$_TOKEN" ]; then echo "Gitea API token not set — skipping Docker login"; exit 1; fi
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images
if: >-
steps.check.outputs.is-release == 'false' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src
run: |
@@ -103,7 +103,7 @@ jobs:
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -117,7 +117,7 @@ jobs:
cleanup:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
if: always() && needs.build-and-push.result == 'success' && needs.build-and-push.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
steps:
@@ -125,10 +125,12 @@ jobs:
with:
fetch-depth: 1
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci
- name: Clean up old image versions
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
+71 -97
View File
@@ -5,18 +5,35 @@ on:
types: [opened, synchronize]
workflow_dispatch:
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
quality:
# Single validation job that merges: quality, detect-changes,
# release-dry-run, pr-review, and pre-merge-check.
# Uses ci-full image (has git-cliff for release-dry-run).
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
validate:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
shell: bash
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
# --- quality steps ---
- name: Lint all
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -27,39 +44,18 @@ jobs:
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
- name: Documentation coverage check
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.doc_coverage --fail-on-missing
- name: Documentation lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.lint_docs --root .
- name: Documentation version reference check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_doc_versions --root .
- name: Vale prose lint check
env:
PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
vale --minAlertLevel=error docs/ AGENTS.md README.md
make devx-docs-check
- name: Translation completeness check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations
@@ -80,92 +76,69 @@ jobs:
else
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
fi
detect-changes:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-image
# --- detect-changes step ---
- name: Detect changed paths
id: detect
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
release-dry-run:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
# --- validate-pr + pr-review steps (PR only) ---
- name: Validate auto-merge preconditions
if: github.event_name == 'pull_request'
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-image
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_auto_merge_ready \
--branch "$HEAD_REF" \
--pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER"
- name: Run automated PR review
if: github.event_name == 'pull_request'
run: |
. .venv/bin/activate 2>/dev/null || true
set -euo pipefail
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
# --- release-dry-run step (conditional) ---
- name: Release dry-run validation
env:
PYTHONPATH: src
if: steps.detect.outputs.user-facing-changed == 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-image
- name: Run automated PR review
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
set -euo pipefail
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}" \
--auto-login
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# Auto-merge runs after validate passes. It reads the task ID
# 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, release-dry-run]
needs: [validate]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
needs.validate.result == 'success'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
@@ -176,15 +149,17 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }}
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Post approval review
env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
@@ -193,13 +168,12 @@ jobs:
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
--body "Auto-approved: all CI checks passed (validate job)."
- name: Squash merge with task ID
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
+115 -252
View File
@@ -1,39 +1,39 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# for release, publish, wiki sync, badges, and Vikunja task updates.
# Runs on every push to master (after CI workflow merges a PR).
# Consolidated into 2 jobs (from 7) to reduce runner overhead:
# detect-and-configure ──→ release-and-maintain
#
# Job dependency graph:
# Job 1: detect release commit, validate commit msg, configure repo
# (branch protection, labels).
# Job 2: release + publish + sync-wiki + vikunja + badges.
# Individual steps are conditional on job 1 outputs.
#
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (needs release — ALWAYS runs, waits for release
# │ so version badge picks up new __version__)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# sync-wiki and vikunja run for ALL non-release commits, not just when
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# The badges job uses `if: always()` and needs `release` so it waits for
# the release job to complete (whether it ran or was skipped). This ensures
# the version badge always reflects the latest __version__ on master.
# Badges run on every push to master, including release commits.
# The badges step always runs (even on release commits) so version
# badge picks up the new __version__. It runs last so it sees the
# new version if release created one.
#
# When release creates a "release: vX.Y.Z" commit and tag, the publish
# job (which depends on release) builds and publishes the package to the
# Gitea PyPI registry. The release commit's post-merge run still updates
# badges (version badge picks up the new version). Other jobs skip.
# step builds and publishes the package to the Gitea PyPI registry.
# The release commit's post-merge run still updates badges. Other
# steps (sync-wiki, vikunja) skip on release commits.
on:
push:
branches: [master]
concurrency:
group: post-merge-${{ github.ref }}
cancel-in-progress: true
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
detect-type:
detect-and-configure:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
@@ -42,175 +42,67 @@ jobs:
shell: bash
outputs:
is-release: ${{ steps.check.outputs.is-release }}
is-automated: ${{ steps.check.outputs.is-automated }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Ensure branch protection and labels
env:
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / validate (pull_request)"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-image
- name: Validate latest commit message
env:
PYTHONPATH: src
if: steps.check.outputs.is-automated == 'false'
run: |
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
- name: Detect changed paths
id: detect
if: steps.check.outputs.is-release == 'false'
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "HEAD~1" \
--head "HEAD" \
--github-output
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
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/detect-and-configure" \
--commit "${{ github.sha }}" \
--auto-login
release:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
release-and-maintain:
needs: [detect-and-configure]
if: always() && needs.detect-and-configure.result == 'success'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
shell: bash
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-image
- name: Configure git
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
- name: Run release
id: release-tag
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}" \
--auto-login
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment
run: make setup-image EXTRAS=release
- name: Build and publish release
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}" \
--auto-login
sync-wiki:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 15
concurrency:
group: sync-wiki-${{ github.repository }}
cancel-in-progress: false
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-image
- name: Sync documentation to wiki
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}" \
--auto-login
badges:
needs: [detect-type, release]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
defaults:
run:
shell: bash
@@ -219,102 +111,73 @@ jobs:
with:
fetch-depth: 0
ref: master
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment
run: make setup-image
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image EXTRAS=release
- name: Configure git
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
# --- release + publish (only if user-facing changes, not a release commit) ---
- name: Run release
id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Build and publish release
if: steps.release-tag.outputs.tag != ''
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
git fetch --tags
git checkout "${{ steps.release-tag.outputs.tag }}"
python3 -m devx.ci.publish "${{ steps.release-tag.outputs.tag }}" "${{ github.repository }}" --auto-login
# --- sync-wiki + vikunja (skip on automated/release commits) ---
- name: Sync documentation to wiki
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Update Vikunja task
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
# --- badges (always run — even on release commits) ---
- name: Generate and push badges
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
# Fetch latest master to pick up any release commit that was pushed
git fetch origin master
git reset --hard origin/master
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \
--commit "${{ github.sha }}" \
--auto-login
vikunja:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-image
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \
--commit "${{ github.sha }}" \
--auto-login
configure-repo:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-image
- name: Ensure branch protection and labels
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \
--workflow "post-merge/release-and-maintain" \
--commit "${{ github.sha }}" \
--auto-login
+4 -10
View File
@@ -73,18 +73,12 @@ repos:
pass_filenames: false
stages: [pre-commit]
- id: doc-coverage
name: documentation coverage check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
language: system
pass_filenames: false
stages: [pre-commit]
- id: lint-docs
name: documentation lint check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
- id: docs-check
name: documentation gate (coverage + stale refs + lint + version refs + prose)
entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_VALE_LEVEL=warning make devx-docs-check'
language: system
pass_filenames: false
always_run: true
stages: [pre-commit]
- id: pytest-cov
+4 -1
View File
@@ -32,14 +32,17 @@ write-good.E-Prime = NO
write-good.So = NO
write-good.ThereIs = NO
write-good.TooWordy = NO
write-good.Passive = NO
# Vale defaults — spelling catches too many technical terms
Vale.Terms = NO
Vale.Repetition = NO
Vale.Spelling = NO
# Readability — warnings only, technical docs are naturally complex
# Readability — technical docs are naturally complex, downgrade to suggestions
Readability.FleschReadingEase = suggestion
Readability.FleschKincaid = suggestion
Readability.AutomatedReadability = suggestion
Readability.ColemanLiau = suggestion
Readability.LIX = suggestion
Readability.GunningFog = suggestion
+1 -1
View File
@@ -3,4 +3,4 @@ message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
level: warning
scope: raw
raw:
- '(?s)```\n(?!.*```)'
- '(?ms)^\n```\n.*?^```\s*$'
+1 -1
View File
@@ -2,7 +2,7 @@ Based on [write-good](https://github.com/btford/write-good).
> Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too.
```text
```
The MIT License (MIT)
Copyright (c) 2014 Brian Ford
+57 -48
View File
@@ -50,7 +50,7 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
Both run via `make workflow-check` and are part of `make lint-all`.
The pre-commit hook runs actionlint automatically when workflow files change.
The CI `quality` job runs `make setup-quality` then `make lint-all`.
The CI `validate` job runs `make setup-image` then `make lint-all`.
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Architecture
@@ -88,7 +88,9 @@ src/devx/
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
│ ├── doc_coverage.py # Documentation coverage check
── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
│ ├── validate_deploy_ref.py # Validate git tag for deployments (--github-output)
│ └── record_deployed_tag.py # Record deployed tag to Gitea repo variable
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
@@ -113,6 +115,16 @@ src/devx/
│ ├── pre_push_check.py # Validate Vikunja task existence before push
│ └── _shared.py # Shared tool utilities
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
├── utils/ # Shared utilities (reusable across projects)
│ ├── api.py # API response helpers (is_truthy, is_falsy)
│ ├── ssh.py # SSH exec + wait_for_ssh (pure-Python socket check)
│ ├── crypto.py # Secret generation (shell-safe passwords)
│ ├── vault.py # Ansible vault encrypt/decrypt helpers
│ ├── network.py # HTTP connectivity check + wait_for_ssh
│ ├── confirm.py # Typed confirmation validation for destructive ops
│ ├── json_registry.py # File-locked JSON registry for local state
│ ├── step_tracker.py # Multi-step operation tracking with reports
│ └── logging.py # XDG-compliant logging configuration
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
@@ -136,18 +148,24 @@ Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings)
Branch protection and labels are automatically configured by
`python -m devx.tools.configure_repo`, which runs as a `configure-repo` job in
the post-merge workflow on every push to master.
`python -m devx.tools.configure_repo`, which runs as a step in the
`detect-and-configure` job in the post-merge workflow on every push to master.
The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
- **Require status checks**: CI quality must pass
- **Require status checks**: CI validate must pass
- **Block force pushes**: No history rewriting on master
### 1. Create Vikunja Task
Create a task in Vikunja to get a `DEVX-N` identifier.
**IMPORTANT:** The task title must NOT include the `DEVX-N:` prefix.
The `make create-pr` and `check_auto_merge_ready` commands automatically
prepend `DEVX-N: ` to the Vikunja task title when forming the PR title.
If the Vikunja task title already includes the prefix, the PR title will
have a double prefix and auto-merge validation will fail.
### 2. Create Branch
```bash
git checkout master && git pull
@@ -174,8 +192,9 @@ docs: update README
### 6. Review the PR
**Automated review (CI `pr-review` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review`. This job posts a review with
**Automated review (CI `validate` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review` as a step in the `validate` job.
This posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found):
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
@@ -198,7 +217,7 @@ Once all checklist items are verified and comments are addressed, approve
the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists
3. Wait for all CI checks to pass (including the `pr-review` job)
3. Wait for all CI checks to pass (including the `validate` job)
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
@@ -209,36 +228,27 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
### Automated Release Pipeline
After a PR is merged to master, the **post-merge workflow**
(`.gitea/workflows/post-merge.yml`) runs automatically:
(`.gitea/workflows/post-merge.yml`) runs automatically. Consolidated
into 2 jobs (from 7) to reduce runner overhead:
1. **detect-type** — Checks if the commit is a regular merge or a
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits (except badges).
1. **detect-and-configure** — Configures repo (branch protection, labels),
detects release commit, validates commit message. Outputs `is-release`
and `is-automated` for the next job.
2. **release** — Runs `python -m devx.ci.release` which:
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
- Uses **git-cliff** to calculate the next semver version from conventional commits
- Updates `__version__` in `src/devx/__init__.py` (single source of truth)
- Updates `CHANGELOG.md` with the new version section
- Runs `make lint-ruff` and `make pytest-cov` to verify the release is healthy
- Commits with `release: vX.Y.Z [skip ci]` prefix
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes both the commit and tag to master
3. **sync-wiki** — Syncs documentation to the Gitea wiki. 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.
Uses `if: always()` so it runs on every push, including release commits.
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.
6. **publish** — Runs after release succeeds (needs: release). Builds and
publishes the package to the Gitea PyPI registry. Gets the tag from the
release job's `tag` output (written via `GITHUB_OUTPUT`).
2. **release-and-maintain** — Runs all post-merge maintenance as
conditional steps:
- **release** (if not a release commit) — Runs `python -m devx.ci.release`
which checks for user-facing changes via `classify_changes`, uses
git-cliff for semver, updates `__version__`, updates `CHANGELOG.md`,
runs lint+tests, commits with `release: vX.Y.Z [skip ci]`, creates
annotated tag, pushes to master.
- **publish** (if release created a tag) — Builds and publishes the
package to the Gitea PyPI registry. Checks out the release tag
within the same job.
- **sync-wiki** (if not automated) — Syncs documentation to the Gitea wiki.
- **vikunja** (if not automated) — Marks the corresponding Vikunja task as done.
- **badges** (always) — Generates and pushes quality badge SVGs to the
`badges` branch. Fetches latest master first to pick up release commits.
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -344,12 +354,11 @@ dependency is skipped, even if the condition explicitly allows
```yaml
auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests]
needs: [validate, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
needs.validate.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
@@ -398,7 +407,7 @@ system loads `.env` automatically via `python-dotenv`.
### pyproject.toml [tool.devx] Configuration
In addition to `DEVX_` env vars, several devx tools read configuration from
In addition to `DEVX_` env vars, many devx tools read configuration from
the `[tool.devx]` section in `pyproject.toml`. This allows per-project
customization without environment variables.
@@ -487,9 +496,9 @@ 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 + hadolint | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | auto-merge, detect-and-configure |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | (badges in release-and-maintain uses ci-full) |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | validate, release-and-maintain, molecule-tests, build-and-push |
**Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
@@ -502,9 +511,9 @@ Each image is tagged `latest` and pushed to
**Using images in workflows**:
```yaml
jobs:
quality:
validate:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -586,7 +595,7 @@ the user should not need to specify which profile to use.
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
| `ci-investigator` | Investigate CI failures (validate, release-and-maintain, build-images) |
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
@@ -596,7 +605,7 @@ the user should not need to specify which profile to use.
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
| CI run failure (validate, release-and-maintain, build-images) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Docker image build/push needed | `docker-image-builder` | Background |
@@ -610,7 +619,7 @@ the user should not need to specify which profile to use.
2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
5. **Don't delegate trivial work** (<30s, <50 lines of context).
5. **Don't delegate minor work** (<30s, <50 lines of context).
6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small.
+78
View File
@@ -2,6 +2,84 @@
All notable changes to this project will be documented in this file.
## [0.40.1] - 2026-07-12
### Bug Fixes
- Fall back to CI token when reviewer self-approval is rejected
## [0.40.0] - 2026-07-11
### Features
- Detect double-prefix in Vikunja task title during pre-merge validation
## [0.39.0] - 2026-07-09
### Features
- Extract shared utilities from infra and grm into devx
## [0.38.0] - 2026-07-08
### Features
- Introduce role-based Gitea API token environment variables
## [0.37.0] - 2026-07-07
### Features
- Consolidate docs checks into devx-docs-check target
## [0.36.2] - 2026-07-07
### Bug Fixes
- GiteaClient.set_repo_variable uses PUT instead of PATCH
## [0.36.1] - 2026-07-07
### Bug Fixes
- Preserve .badges/ dir during git clean in push_badges
## [0.36.0] - 2026-07-07
### Features
- Add GiteaClient repo variable methods and parallelize pytest-cov
## [0.35.7] - 2026-07-06
### Bug Fixes
- Use Gitea wiki dash-marker filename convention
## [0.35.6] - 2026-07-06
### Bug Fixes
- Add delay before wiki verification to avoid race condition
## [0.35.5] - 2026-07-06
### Bug Fixes
- Embed token in wiki clone URL for push auth
## [0.35.4] - 2026-07-06
### Bug Fixes
- Configure git identity before commit in sync_wiki
## [0.35.3] - 2026-07-06
### Bug Fixes
- Replace --strict with --verify for sync_wiki
## [0.35.2] - 2026-07-06
### Bug Fixes
+10 -10
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.35.2",
"devx>=0.40.1",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.35.2"`) or use a version constraint
> (for example, `"devx>=0.35.2,<0.36"`).
> `dependencies` (for example, `"devx==0.40.1"`) or use a version constraint
> (for example, `"devx>=0.40.1,<0.41"`).
### Optional extras
@@ -372,7 +372,7 @@ infrastructure = []
# Files that would default to user-facing but are actually infrastructure
infrastructure_overrides = [
"src/myproject/__init__.py", # only contains __version__
"src/myproject/__init__.py", # example only — only contains __version__
]
# Safety override for broad infrastructure patterns
+1 -1
View File
@@ -13,7 +13,7 @@ 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 \
RUN python3 -m devx.tools.install_tools --tool actionlint --tool vale \
&& python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter)
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.35.2",
"devx>=0.40.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.35.2"` or `"devx>=0.35.2,<0.36"`.
Pin a specific version if needed: `"devx==0.40.1"` or `"devx>=0.40.1,<0.41"`.
### Optional extras
+52 -45
View File
@@ -122,7 +122,9 @@ exponential backoff (2s, 4s, 8s).
- Labels (list, create, add to issues)
- Issues (create, list)
- Pull requests (get commits, merge, create review)
- Releases (list)
- Releases (list, create idempotent)
- Actions (list runs, list jobs, get job logs)
- Actions variables (get, set idempotent)
- Wiki pages (list, fetch, create, update, delete)
**`VikunjaClient`** — Vikunja REST API wrapper:
@@ -309,7 +311,7 @@ from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
### `setup.py`
Project setup: installs Python dependencies (editable mode with extras),
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
profile from `.env`. Supports `--extras` to specify dependency groups,
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
@@ -335,7 +337,7 @@ Configures repository branch protection and labels via the Gitea REST API.
Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch) and creates standard labels. Status check
contexts are read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`.
`CI / validate (pull_request)`.
### `generate_badges.py`
@@ -454,13 +456,14 @@ Developer pushes and creates PR (title: "DEVX-N: <vikunja task title>")
CI workflow (ci.yml) triggers:
├── quality (lint, tests, coverage, test speed, doc coverage,
translation check, dependency scan, workflow dry-run)
├── detect-changes (classify_changes.py → user-facing or workflow-only)
── if user-facing → release-dry-run (release.py --dry-run)
├── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
├── validate (single job: quality + detect-changes +
release-dry-run + pr-review + pre-merge validation)
├── quality steps (lint, tests, coverage, test speed, doc coverage,
│ │ translation check, dependency scan, workflow dry-run)
── detect-changes (classify_changes.py → user-facing or workflow-only)
│ └── if user-facing → release-dry-run (release.py --dry-run)
├── pre-merge validation (check_auto_merge_ready.py)
│ └── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
└── auto-merge (auto_merge.py)
├── validate PR title format
@@ -481,50 +484,54 @@ Push to master (squash-merge commit: "DEVX-N <conventional commit>")
Post-merge workflow (post-merge.yml) triggers:
├── detect-type (detect_release_commit.py)
── is-release? → skip all jobs except badges
├── detect-and-configure (single job)
── configure-repo (configure_repo.py)
│ ├── detect-type (detect_release_commit.py)
│ │ └── is-release? → skip all steps except badges
│ └── validate-commit-msg (validate_commit_msg.py --branch master)
── validate-commit-msg (validate_commit_msg.py --branch master)
├── release (release.py)
│ ├── classify_changes.py → skip if workflow-only
│ ├── git-cliff → calculate next version
│ ├── update __version__ in __init__.py
│ ├── update CHANGELOG.md
│ ├── run make lint-ruff && make pytest-cov
│ ├── commit "release: vX.Y.Z [skip ci]"
── create annotated tag vX.Y.Z
└── push commit + tag to master
│ ▼
Tag push triggers publish workflow (see below)
├── sync-wiki (sync_wiki.py --strict)
└── sync docs/ to Gitea wiki with integrity check
├── badges (push_badges.py) [ALWAYS runs, even on release commits]
├── fetch latest master
── generate_badges.py → SVG files
│ ├── push to orphan badges branch
── update README.md + docs/index.md with cache-busting URLs
├── vikunja (post_merge.py)
├── extract task ID from commit message
│ ├── mark Vikunja task as done
└── post comment with merge SHA
└── configure-repo (configure_repo.py)
└── ensure branch protection and labels
── release-and-maintain (needs detect-and-configure)
├── release (release.py) [skip if release commit or workflow-only]
│ ├── classify_changes.py → skip if workflow-only
│ ├── git-cliff → calculate next version
│ ├── update __version__ in __init__.py
│ ├── update CHANGELOG.md
│ ├── run make lint-ruff && make pytest-cov
│ ├── commit "release: vX.Y.Z [skip ci]"
│ ├── create annotated tag vX.Y.Z
── push commit + tag to master
│ publish (publish.py) [if release created a tag]
├── build package (python -m build)
│ ├── publish to Gitea PyPI registry (twine upload)
│ │ OR publish to standard PyPI (if PYPI_TOKEN set)
│ OR skip publish (if --skip-build)
│ └── create Gitea release with git-cliff notes
├── sync-wiki (sync_wiki.py --strict) [skip if automated]
── sync docs/ to Gitea wiki with integrity check
── vikunja (post_merge.py) [skip if automated]
│ ├── extract task ID from commit message
├── mark Vikunja task as done
│ └── post comment with merge SHA
└── badges (push_badges.py) [ALWAYS runs, even on release commits]
├── fetch latest master
├── generate_badges.py → SVG files
├── push to orphan badges branch
└── update README.md + docs/index.md with cache-busting URLs
```
### Publish flow
```text
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
Within release-and-maintain job (after release step creates a tag):
├── install build, twine, git-cliff, tea
├── configure tea login
├── checkout release tag
└── publish (publish.py)
├── build package (python -m build)
+146 -108
View File
@@ -1,32 +1,29 @@
# CI/CD Workflow
devx uses Gitea Actions for CI/CD automation. Three workflows implement a
complete pipeline: pull request validation, post-merge release automation, and
tag-triggered publishing.
devx uses Gitea Actions for CI/CD automation. Two workflows implement a
complete pipeline: pull request validation and post-merge release
automation (including publishing).
## Workflow overview
```text
PR opened/synchronized ──► CI (ci.yml)
│ ├── quality
├── detect-changes
├── release-dry-run (if user-facing)
│ ├── pr-review
│ ├── validate (quality + detect-changes +
│ release-dry-run + pr-review +
│ pre-merge validation)
│ └── auto-merge ──► squash-merge to master
│ │
▼ ▼
Push to master ──► Post-merge (post-merge.yml)
├── detect-type
├── validate-commit-msg
├── release ──► tag vX.Y.Z
── sync-wiki │
├── badges │
├── vikunja │
└── configure-repo │
Tag push (v*) ──► Publish (publish.yml)
└── publish ──► Gitea PyPI registry + Gitea release
├── detect-and-configure (detect-type +
validate-commit-msg +
│ configure-repo)
── release-and-maintain
├── release ──► tag vX.Y.Z
├── publish ──► Gitea PyPI registry + Gitea release
├── sync-wiki
├── vikunja
└── badges (always runs)
```
## CI workflow (`ci.yml`)
@@ -35,9 +32,15 @@ Runs on pull requests (opened and synchronize) and manual dispatch.
### Jobs
#### `quality`
#### `validate`
The main quality gate. Runs on every PR:
The single validation job. Consolidates the former `quality`,
`detect-changes`, `release-dry-run`, `pr-review`, and `pre-merge-check`
jobs into one job to save checkout+setup overhead. Runs on every PR.
**Quality steps**
The main quality gate:
1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
(via `make lint-all`)
@@ -52,21 +55,21 @@ The main quality gate. Runs on every PR:
7. **Workflow dry-run validation**`make workflow-dryrun` via act_runner
(best-effort, skipped if act_runner is not installed)
#### `detect-changes`
**`detect-changes` step**
Classifies changes between `origin/master` and the PR head as user-facing or
workflow-only using `python -m devx.ci.classify_changes --github-output`.
Writes `user-facing-changed=true|false` to the job output for use by
downstream jobs.
downstream steps.
#### `release-dry-run`
**`release-dry-run` step**
Depends on `quality` and `detect-changes`. Only runs if user-facing changes
are detected. Runs `python -m devx.ci.release --dry-run` to validate that
the release script can calculate the next version and generate the changelog
without making changes. Non-blocking (uses `|| true`).
Only runs if the detect-changes step detected user-facing changes. Runs
`python -m devx.ci.release --dry-run` to validate that the release script
can calculate the next version and generate the changelog without making
changes. Non-blocking (uses `|| true`).
#### `pr-review`
**`pr-review` step**
Runs on every pull request. Executes `python -m devx.ci.pr_review` with the
PR number and repository. Fetches the PR diff via the Gitea API and runs
@@ -87,11 +90,24 @@ Checks performed:
7. Test coverage — source changes must include test updates
8. Commit conventions — conventional commit format on PR commits
**Pre-merge validation step**
Runs on every pull request. Executes
`python -m devx.ci.check_auto_merge_ready` with the branch name, PR title,
repository, and PR number. Validates auto-merge preconditions before the
`auto-merge` job runs:
1. **Branch name** — must contain a valid task ID (for example,
`DEVX-12-fix-foo``DEVX-12`)
2. **PR title format** — must be `{PREFIX}-N: <vikunja task title>`
3. **Vikunja task** — must exist and the title must match the PR title
4. **Branch state** — must not be behind master
#### `auto-merge`
Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
title, repository, and PR number:
Depends on `validate`. The final job in the CI workflow. Runs
`python -m devx.ci.auto_merge` with the branch name, PR title, repository,
and PR number:
1. **Read task ID** from branch name (for example, `DEVX-12-fix-foo``DEVX-12`)
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
@@ -107,8 +123,9 @@ The merge commit push to master triggers the post-merge workflow.
### Smart CI: user-facing vs workflow-only changes
Not all changes require a new release. The `detect-changes` job classifies
changes using `python -m devx.ci.classify_changes`:
Not all changes require a new release. The `detect-changes` step in the
`validate` job classifies changes using
`python -m devx.ci.classify_changes`:
**Workflow-only paths** (infrastructure — no release needed):
- `.gitea/**` — Gitea Actions workflows
@@ -137,55 +154,90 @@ Rule priority (first match wins):
## Post-merge workflow (`post-merge.yml`)
Runs on every push to master. A single workflow with conditional jobs
replaces separate workflows for release, wiki sync, badges, and Vikunja task
updates.
Runs on every push to master. Consolidated into 2 jobs (from 7) to reduce
runner overhead: `detect-and-configure` (detect-type + validate-commit-msg +
configure-repo) and `release-and-maintain` (release + publish + sync-wiki +
badges + vikunja). Individual steps within `release-and-maintain` are
conditional on the `detect-and-configure` job's outputs.
### Job dependency graph
```text
detect-type ──┬── validate-commit-msg (skip if release commit)
├── release (skip if release commit)
│ │
│ ├── sync-wiki (needs release)
│ ├── badges (needs release, ALWAYS runs)
│ └── vikunja (needs release)
└── configure-repo (independent, skip if release commit)
detect-and-configure
├── configure-repo (independent, skip if release commit)
├── detect-type → is-release? is-automated?
└── validate-commit-msg (skip if release commit)
release-and-maintain (needs detect-and-configure)
├── release (skip if release commit or workflow-only)
│ └── publish (if release created a tag)
├── sync-wiki (skip if automated)
├── vikunja (skip if automated)
└── badges (always runs)
```
`sync-wiki` and `vikunja` depend on `release` succeeding so that the wiki and
task tracker are only updated when the code is actually released. If release
fails, they are skipped to avoid leaving the wiki or Vikunja in an
inconsistent state.
`sync-wiki` and `vikunja` run only on non-automated commits (that is, real PR
merges) so that the wiki and task tracker are only updated when a human
change lands. They skip on release commits and automated commits.
The `badges` job uses `if: always()` with no is-release condition so it runs
on every push to master, including release commits. This ensures badges
(tests, coverage, version, etc.) are always current.
The `badges` step always runs (even on release commits) so badges (tests,
coverage, version, etc.) are always current. It runs last so it picks up
any version bump the release step created.
When `release` creates a `release: vX.Y.Z` commit, the release commit's
post-merge run still updates badges (the version badge picks up the new
version). Other jobs skip. The tag push triggers `publish.yml`.
version). Other steps skip. The `publish` step builds and publishes the
package to the Gitea PyPI registry within the same `release-and-maintain`
job (it checks out the release tag).
### Post-merge jobs
#### `detect-type`
#### `detect-and-configure`
The first post-merge job. Consolidates the former `detect-type`,
`validate-commit-msg`, and `configure-repo` jobs. Outputs `is-release`,
`is-automated`, and `user-facing-changed` for the `release-and-maintain`
job.
**`detect-type` step**
Checks if the latest commit is a release commit (`release: vX.Y.Z [skip ci]`)
using `python -m devx.ci.detect_release_commit`. Writes `is-release=true` or
`is-release=false` to the job output. All subsequent jobs use this to
conditionally skip for release commits.
`is-release=false` (and `is-automated`) to the job output. The
`release-and-maintain` job uses these to conditionally skip steps for
release commits.
#### `validate-commit-msg`
**`validate-commit-msg` step**
Depends on `detect-type`. Skips for release commits. Validates the latest
commit message using `python -m devx.ci.validate_commit_msg --branch master`.
On master, commits must follow `{PREFIX}-N: <conventional commit>` format
(added by auto-merge).
Skips for release/automated commits. Validates the latest commit message
using `python -m devx.ci.validate_commit_msg --branch master`. On master,
commits must follow `{PREFIX}-N: <conventional commit>` format (added by
auto-merge).
#### `release`
**`configure-repo` step**
Depends on `detect-type`. Skips for release commits. The core release
automation job. Runs `python -m devx.ci.release`:
Ensures branch protection and labels are configured using
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
- Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch)
- Creates standard labels
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
`CI / validate (pull_request)`
On failure, the `notify_failure` step creates a Gitea issue.
#### `release-and-maintain`
Depends on `detect-and-configure`. The second post-merge job. Consolidates
the former `release`, `publish`, `sync-wiki`, `badges`, and `vikunja` jobs.
Individual steps are conditional on the `detect-and-configure` job's outputs.
**`release` step**
Skips for release commits and workflow-only changes. The core release
automation step. Runs `python -m devx.ci.release`:
1. **Classify changes** — calls `classify_changes.py` to check for user-facing
changes. If only infrastructure files changed, exits without releasing.
@@ -225,11 +277,10 @@ tag/version/commit alignment.
On failure, the `notify_failure` step creates a Gitea issue via
`python -m devx.ci.notify_failure`.
#### `sync-wiki`
**`sync-wiki` step**
Depends on `detect-type` and `release`. Skips for release commits. Syncs
documentation from `docs/` to the Gitea wiki using
`python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
Skips for automated commits. Syncs documentation from `docs/` to the Gitea
wiki using `python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
1. Reads `docs/mapping.json` to map file paths to wiki page titles
2. Lists existing wiki pages via the Gitea API
@@ -243,15 +294,14 @@ deleted).
On failure, the `notify_failure` step creates a Gitea issue.
#### `badges`
**`badges` step**
Depends on `detect-type` and `release`. Uses `if: always()` so it runs on
every push to master, including release commits. Generates and pushes quality
badges using `python -m devx.ci.push_badges`:
Always runs (even on release commits). Generates and pushes quality badges
using `python -m devx.ci.push_badges`:
1. **Fetch latest master** — `git fetch origin master && git reset --hard
origin/master` (ensures the version badge reflects the current state,
even if the release job just pushed a new version)
even if the release step recently pushed a new version)
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
@@ -268,11 +318,10 @@ and waits 10s between attempts).
On failure, the `notify_failure` step creates a Gitea issue.
#### `vikunja`
**`vikunja` step**
Depends on `detect-type` and `release`. Skips for release commits. Updates
the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
<sha>`:
Skips for automated commits. Updates the Vikunja task after a merge using
`python -m devx.ci.post_merge --git-sha <sha>`:
1. Extracts the task ID from the first line of the commit message
2. Marks the corresponding Vikunja task as done
@@ -280,26 +329,11 @@ the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
On failure, the `notify_failure` step creates a Gitea issue.
#### `configure-repo`
**`publish` step**
Depends on `detect-type`. Skips for release commits. Ensures branch
protection and labels are configured using
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
- Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch)
- Creates standard labels
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`
On failure, the `notify_failure` step creates a Gitea issue.
## Publish workflow (`publish.yml`)
Runs on tag pushes matching `v*`. Triggered by the `release` job in the
post-merge workflow when it creates and pushes a new version tag.
### Job: `publish`
Only runs if the `release` step created a tag. Builds and publishes the
package within the same `release-and-maintain` job (checks out the release
tag). Runs `python -m devx.ci.publish <tag> <owner/repo>`:
1. **Install dependencies** — build, twine, requests, python-dotenv, click,
and the project itself
@@ -518,25 +552,29 @@ The complete release process from PR to published package:
1. **PR merged**`auto-merge` squash-merges the PR to master with
`{PREFIX}-N <conventional commit>` title
2. **Post-merge triggers** — the merge push triggers `post-merge.yml`
3. **detect-type** — confirms the commit is not a release commit
4. **release**`release.py` calculates the next version, updates files,
runs tests, commits `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`,
and pushes to master
5. **Tag push triggers publish** — the tag push triggers `publish.yml`
6. **publish**`publish.py` builds the package, publishes to the Gitea PyPI
registry, and creates a Gitea release with git-cliff notes
7. **sync-wiki** — documentation is synced to the Gitea wiki
8. **badges** — quality badges are regenerated and pushed to the `badges`
branch; README and docs/index.md are updated with cache-busting URLs
9. **vikunja** — the corresponding Vikunja task is marked as done
10. **configure-repo** — branch protection and labels are ensured
3. **detect-and-configure** — detects release commit, validates commit
message, and ensures branch protection/labels
4. **release** (step in `release-and-maintain`) — `release.py` calculates
the next version, updates files, runs tests, commits
`release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`, and pushes to master
5. **publish** (step in `release-and-maintain`) `publish.py` builds the
package, publishes to the Gitea PyPI registry, and creates a Gitea
release with git-cliff notes (checks out the release tag within the
same job)
6. **sync-wiki** (step in `release-and-maintain`) — documentation is synced
to the Gitea wiki
7. **vikunja** (step in `release-and-maintain`) — the corresponding Vikunja
task is marked as done
8. **badges** (step in `release-and-maintain`) — quality badges are
regenerated and pushed to the `badges` branch; README and docs/index.md
are updated with cache-busting URLs
The release commit's post-merge run skips all jobs except `badges` (which
The release commit's post-merge run skips all steps except `badges` (which
picks up the new version number). This prevents infinite loops.
## Failure handling
Every job in the post-merge and publish workflows has a `notify_failure` step
Every job in the CI and post-merge workflows has a `notify_failure` step
that runs `if: failure()`. This creates a Gitea issue with the workflow name,
run ID, and commit SHA, ensuring failures that would otherwise go unnoticed
in the Actions tab are surfaced as issues. The issue is created via the tea
+1 -1
View File
@@ -405,7 +405,7 @@ devx tools install-tools --list # list status
### `devx tools setup`
Project setup: install Python dependencies (editable mode with extras),
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
profile from `.env`.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.35.2",
"devx>=0.40.1",
]
[project.optional-dependencies]
dev = [
"devx>=0.35.2",
"devx>=0.40.1",
]
```
+6
View File
@@ -121,6 +121,12 @@ vikunja_project_id = 8
repo_owner = "oblachno-oss"
repo_name = "devx"
[tool.devx.check_agent_docs]
skip_ref_prefixes = [
"src/myproject/",
"ansible/requirements.yml",
]
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe)
[tool.devx.classify]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.35.2"
__version__ = "0.40.1"
+30
View File
@@ -372,6 +372,36 @@ class GiteaClient:
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
return r.text
# -- actions variables (repo-level) --
def get_repo_variable(self, name: str) -> str | None:
"""Read a Gitea Actions repository variable.
Returns the variable value, or ``None`` if the variable is not set.
Raises :class:`APIError` on other HTTP errors.
"""
try:
r = self._request("GET", f"/actions/variables/{name}")
return r.json().get("value")
except APIError as e:
if e.status == 404:
return None
raise
def set_repo_variable(self, name: str, value: str) -> None:
"""Create or update a Gitea Actions repository variable (idempotent).
Tries PUT first (update); if the variable doesn't exist (404),
creates it via POST. Gitea 1.26.x does not support PATCH for
action variables.
"""
try:
self._request("PUT", f"/actions/variables/{name}", json={"value": value})
except APIError as e:
if e.status != 404:
raise
self._request("POST", f"/actions/variables/{name}", json={"value": value})
class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling."""
+12 -8
View File
@@ -17,10 +17,9 @@ This allows the PR title to be a human-friendly Vikunja task title
while the squashed commit follows conventional commits.
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
CI_GITEA_API_TOKEN=<token> VIKUNJA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
"""
import os
import re
from pathlib import Path
from typing import Any
@@ -40,6 +39,7 @@ from devx.config import (
)
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_ci_token, get_vikunja_token
# Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects.
_TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*")
@@ -115,9 +115,12 @@ def get_vikunja_task_title(task_id: str) -> str:
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
try:
token = get_vikunja_token()
except click.ClickException:
raise click.ClickException(
_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.")
) from None
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
@@ -197,9 +200,10 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
@click.argument("repo")
@click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
try:
token = get_ci_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
# Validate PR number is an integer
try:
+39 -15
View File
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
Usage::
# CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
# CI (with VIKUNJA_TOKEN and CI_GITEA_API_TOKEN):
python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\
@@ -34,13 +34,12 @@ 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 ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only
If ``CI_GITEA_API_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
@@ -55,6 +54,7 @@ from devx.config import (
)
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_ci_token, get_vikunja_token
load_dotenv()
@@ -99,10 +99,13 @@ def is_branch_behind_master(branch: str) -> bool:
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API.
Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched.
Returns ``None`` if no token is set or the PR cannot be fetched.
"""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token or "/" not in repo:
try:
token = get_ci_token()
except click.ClickException:
return None
if "/" not in repo:
return None
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
@@ -120,8 +123,9 @@ def get_vikunja_title_optional(task_id: str) -> str | None:
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:
try:
token = get_vikunja_token()
except click.ClickException:
return None
client = VikunjaClient(VIKUNJA_API_URL, token)
from devx.config import DEFAULT_PER_PAGE
@@ -221,7 +225,11 @@ def cli(
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", ""))
try:
get_vikunja_token()
token_set = True
except click.ClickException:
token_set = False
if token_set:
errors.append(
_(
@@ -233,17 +241,33 @@ def cli(
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:
# Defensive check: warn if the Vikunja task title already includes
# the task ID prefix. The expected PR title is
# f"{task_id}: {vikunja_title}" — if vikunja_title already starts
# with "{task_id}:", the PR title will have a double prefix.
if vikunja_title.startswith(f"{task_id}:"):
errors.append(
_(
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
expected=expected,
title=pr_title,
"Vikunja task title '{title}' starts with '{prefix}:'. "
"The task title should NOT include the '{prefix}' prefix — "
"it is automatically added to the PR title. "
"Update the Vikunja task title to remove the prefix.",
title=vikunja_title,
prefix=task_id,
),
)
else:
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
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:
+1
View File
@@ -185,6 +185,7 @@ def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
# Try common locations
candidates = [
root / "src" / "devx" / "translations.json",
root / "src" / "grm" / "translations.json",
]
# Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"):
+6 -2
View File
@@ -31,6 +31,7 @@ import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3
@@ -96,7 +97,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
return total
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
"""Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default.
@@ -152,7 +153,10 @@ def main(
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "")
try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+63 -8
View File
@@ -21,6 +21,7 @@ from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
# Default to the current working directory (consuming repo's root)
@@ -71,12 +72,30 @@ def extract_cli_commands(source_dir: Path) -> list[str]:
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
for match in re.finditer(r"@\w+\.command\b", content):
# Check for explicit name="..." in the decorator arguments
decorator_end = content.find(")", match.start())
# Use a balanced paren search to find the end of the decorator
# (handles nested parens like @cli.command(help=_("...")))
depth = 0
decorator_end = match.start()
for i in range(match.start(), len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
decorator_end = i
break
decorator_text = content[match.start() : decorator_end + 1]
name_match = re.search(r'["\']([^"\']+)["\']', decorator_text)
# Look for explicit name="..." parameter (not help=, not other kwargs)
name_match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_text)
if name_match:
commands.append(name_match.group(1))
continue
# Look for a positional string argument (e.g. @cli.command("my-cmd"))
# but skip if the only strings are in help= or other keyword args
positional_match = re.search(r'@\w+\.command\s*\(\s*["\']([^"\']+)["\']', decorator_text)
if positional_match:
commands.append(positional_match.group(1))
continue
# Find the next def statement after this decorator
after = content[decorator_end:]
def_match = re.search(r"def\s+(\w+)\s*\(", after)
@@ -106,16 +125,38 @@ def check_module_documented(module: str, docs_content: str) -> bool:
@click.command()
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
@click.option(
"--ci-scripts-dir",
default=None,
help=(
"Path to CI scripts directory (default: auto-detect from src/ci/). "
"Set to empty string to skip CI script checks."
),
)
@click.option(
"--fail-on-missing",
is_flag=True,
default=False,
help="Exit with non-zero status if any documentation is missing.",
)
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
def main(docs_dir: str | None, source_dir: str | None, ci_scripts_dir: str | None, fail_on_missing: bool) -> None:
root = Path.cwd()
docs_path = Path(docs_dir) if docs_dir else root / "docs"
# Read [tool.devx.doc_coverage] config from pyproject.toml
devx_cfg = _load_pyproject_devx()
doc_cov_cfg_raw: object = devx_cfg.get("doc_coverage", {}) if isinstance(devx_cfg, dict) else {}
doc_cov_cfg: dict[str, object] = doc_cov_cfg_raw if isinstance(doc_cov_cfg_raw, dict) else {}
# CLI args override config; config overrides defaults
if ci_scripts_dir is None and "ci_scripts_dir" in doc_cov_cfg:
ci_scripts_dir = str(doc_cov_cfg["ci_scripts_dir"])
if docs_dir is None and "docs_dir" in doc_cov_cfg:
docs_dir = str(doc_cov_cfg["docs_dir"])
docs_path = Path(docs_dir)
if source_dir is None and "source_dir" in doc_cov_cfg:
source_dir = str(doc_cov_cfg["source_dir"])
# Auto-detect source directory
if source_dir:
src_path = Path(source_dir)
@@ -166,13 +207,27 @@ def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) ->
missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md
# Auto-detect CI scripts from ci/ subdirectory
# Auto-detect CI scripts from ci/ subdirectory, or use explicit config
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
if ci_scripts_dir is not None:
# Explicit config — empty string means skip CI script checks
if ci_scripts_dir == "":
detected_scripts = []
else:
ci_dir = Path(ci_scripts_dir)
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
else:
detected_scripts = []
else:
detected_scripts = REQUIRED_SCRIPTS
# Auto-detect from src_path/ci/
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
else:
# No ci/ directory found — skip CI script checks rather than falling back
# to REQUIRED_SCRIPTS (which is devx-specific)
detected_scripts = []
total += len(detected_scripts)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in detected_scripts:
+6 -2
View File
@@ -17,7 +17,7 @@ Usage::
Environment variables:
GITEA_URL Base URL of the Gitea instance.
CI_GITEA_TOKEN API token with repo access.
CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index).
@@ -41,6 +41,7 @@ from devx.i18n import _
from devx.molecule.molecule_ci_guard import (
poll_for_other_failures,
)
from devx.tokens import get_ci_token
POLL_INTERVAL = 10
@@ -50,7 +51,10 @@ POLL_INTERVAL = 10
def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
try:
token = get_ci_token()
except click.ClickException:
token = None
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
+7 -6
View File
@@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
for issue creation tea must be installed and configured.
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.notify_failure \
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
@@ -14,14 +14,13 @@ Usage:
--auto-login
With ``--auto-login``, the script configures the tea CLI login profile
from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
from the CI API token and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow.
"""
from __future__ import annotations
import logging
import os
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -29,6 +28,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv()
@@ -74,9 +74,10 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.",
)
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
try:
get_ci_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if auto_login:
configure_tea_login()
+5 -4
View File
@@ -5,7 +5,6 @@ Usage:
VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>]
"""
import os
import re
import subprocess # nosec B404
@@ -17,6 +16,7 @@ from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv()
@@ -127,9 +127,10 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
commit_sha = _get_git_commit_sha()
if not commit_msg:
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
try:
token = get_vikunja_token()
except click.ClickException:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) from None
task_id = extract_task_id(commit_msg)
if not task_id:
+38 -8
View File
@@ -17,7 +17,7 @@ Checks performed:
8. Commit conventions conventional commit format on branch commits
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
CI_GITEA_API_TOKEN=<token> [REVIEWER_GITEA_API_TOKEN=<token>] python3 -m devx.ci.pr_review <pr_number> <owner/repo>
"""
from __future__ import annotations
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_ci_token, get_reviewer_token
load_dotenv()
@@ -548,8 +549,14 @@ def _post_manual_review(
checklist_confirmed: bool,
checklist_categories: str | None,
dry_run: bool,
owner: str | None = None,
repo_name: str | None = None,
) -> None:
"""Post a manual review with validation for APPROVE events."""
"""Post a manual review with validation for APPROVE events.
When self-approval is rejected (reviewer token belongs to PR author),
falls back to the CI token (different user) if available.
"""
if not body or len(body) < 50:
raise click.ClickException(_("Review body must be at least 50 characters."))
@@ -585,8 +592,20 @@ def _post_manual_review(
review = client.create_review(pr_number, event=event, body=body)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
# Self-approval not allowed (reviewer token belongs to PR author).
# Fall back to CI token (different user) if available.
ci_token = os.environ.get("CI_GITEA_API_TOKEN", "").strip()
if ci_token and owner and repo_name:
click.echo(_("Note: Self-approval not allowed with reviewer token. Retrying with CI token."))
ci_client = GiteaClient(GITEA_API_URL, ci_token, owner, repo_name)
try:
review = ci_client.create_review(pr_number, event=event, body=body)
except APIError:
click.echo(_("Note: CI token also cannot approve. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
@@ -636,15 +655,26 @@ def main(
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
With --event: posts a manual review (skips automated checks).
"""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
try:
token = get_reviewer_token() if (event and event.upper() == "APPROVE") else get_ci_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if event is not None:
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
_post_manual_review(
client,
pr_number,
event.upper(),
body,
checklist_confirmed,
checklist_categories,
dry_run,
owner=owner,
repo_name=repo_name,
)
return
result = run_review(client, pr_number)
+8 -6
View File
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the
``CI_GITEA_TOKEN`` as both username and password.
CI API token as both username and password.
2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow.
3. **Skip** if neither is configured, only the Gitea release is created.
Usage:
CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
CI_GITEA_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
CI_GITEA_API_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
"""
import os
@@ -31,6 +31,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv()
@@ -253,9 +254,10 @@ def main(
if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("CI_GITEA_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
try:
gitea_token = get_ci_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
pypi_token = os.environ.get("PYPI_TOKEN", "")
+2 -2
View File
@@ -93,8 +93,8 @@ def push_to_badges_branch(badges_dir: str) -> str:
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
_run(["git", "rm", "-rf", "."]) # nosec B607
# Remove untracked files/dirs left behind (e.g. .badges/ from generate_badges)
_run(["git", "clean", "-fdx", "-e", ".git"]) # nosec B607
# Remove untracked files/dirs left behind, but preserve .badges/ for copy below
_run(["git", "clean", "-fdx", "-e", ".git", "-e", badges_dir]) # nosec B607
# Copy badge files to root
for svg in Path(badges_dir).glob("*.svg"):
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Record the deployed git tag for a given environment.
Writes the tag to a Gitea repository variable so it can be queried
later via the Gitea API or ``devx.ci.get_deployed_tag``.
Usage::
python -m devx.ci.record_deployed_tag --env production --tag v0.28.1
python -m devx.ci.record_deployed_tag --env staging --tag master-abc1234
"""
from __future__ import annotations
import sys
import click
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_ci_token
@click.command()
@click.option(
"--env",
"env_name",
type=click.Choice(["staging", "production"]),
required=True,
)
@click.option("--tag", required=True, help=_("Git tag or ref that was deployed"))
def main(env_name: str, tag: str) -> None:
"""Record the deployed tag for the given environment."""
try:
token = get_ci_token()
except click.ClickException as exc:
click.echo(f"Error: {exc.message}", err=True)
sys.exit(1)
var_name = f"{env_name.upper()}_DEPLOY_TAG"
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
client.set_repo_variable(var_name, tag)
click.echo(f"Recorded {var_name} = {tag}")
if __name__ == "__main__": # pragma: no cover
main()
+1 -1
View File
@@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment
"""
+53 -15
View File
@@ -21,7 +21,7 @@ Link transformations:
- Anchor-only links (``#section``) are preserved
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
"""
from __future__ import annotations
@@ -31,13 +31,16 @@ import os
import re
import subprocess # nosec B404
import tempfile
import time
from pathlib import Path
from urllib.parse import quote, urlparse
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv()
@@ -48,6 +51,23 @@ MAPPING_FILE = DOCS_DIR / "mapping.json"
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
def wiki_filename(page_title: str) -> str:
"""Convert a wiki page title to its Gitea wiki filename.
Gitea uses a "dash marker" (``.-``) suffix to distinguish literal dashes
from space-to-dash conversions. See Gitea's ``services/wiki/wiki_path.go``.
- "Architecture" (no dashes) ``Architecture.md``
- "Getting-Started" (has dashes) ``Getting-Started.-.md``
- "Home" (no dashes) ``Home.md``
"""
name = page_title.replace(" ", "-")
if "-" in name:
name += ".-"
name += ".md"
return quote(name, safe="")
def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json."""
with open(MAPPING_FILE, encoding="utf-8") as f:
@@ -102,7 +122,10 @@ def get_wiki_clone_url(owner: str, repo: str, token: str) -> str:
# Gitea wiki repos are at {clone_url}.wiki.git
# Extract base URL from API URL
base = GITEA_API_URL.rsplit("/api/v1", 1)[0]
return f"{base}/{owner}/{repo}.wiki.git"
# Embed token in URL for both clone and push auth
# Format: https://token@host/owner/repo.wiki.git
parsed = urlparse(base)
return f"{parsed.scheme}://{token}@{parsed.hostname}/{owner}/{repo}.wiki.git"
def clone_wiki(wiki_url: str, dest: Path) -> bool:
@@ -166,16 +189,15 @@ def sync_files(
# Transform links
transformed = transform_links(content)
# Wiki filename: use the page title with spaces → underscores
# Gitea wiki uses the page title as filename (spaces become dashes)
wiki_filename = page_title.replace(" ", "-") + ".md"
expected_files.add(wiki_filename)
# Wiki filename: Gitea uses a dash-marker convention for titles with dashes
fname = wiki_filename(page_title)
expected_files.add(fname)
if not dry_run:
dest = wiki_dir / wiki_filename
dest = wiki_dir / fname
dest.write_text(transformed, encoding="utf-8")
synced += 1
click.echo(_(" Synced: {title}{file}", title=page_title, file=wiki_filename))
click.echo(_(" Synced: {title}{file}", title=page_title, file=fname))
# Prune stale pages (in wiki but not in mapping)
pruned = 0
@@ -208,7 +230,19 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
click.echo(_("No changes to sync — wiki is up to date."))
return False
# Commit
# Commit — ensure git identity is configured (CI environments may lack it)
subprocess.run( # nosec
["git", "config", "user.email", "devin-ai-integration[bot]@users.noreply.github.com"],
cwd=wiki_dir,
capture_output=True,
check=True,
)
subprocess.run( # nosec
["git", "config", "user.name", "Devin CI"],
cwd=wiki_dir,
capture_output=True,
check=True,
)
subprocess.run( # nosec
["git", "commit", "-m", "Sync wiki from docs/ [skip ci]"],
cwd=wiki_dir,
@@ -218,7 +252,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
# Push
result = subprocess.run( # nosec
["git", "push", wiki_url, "HEAD:master"],
["git", "push", "--force", wiki_url, "HEAD:master"],
cwd=wiki_dir,
capture_output=True,
text=True,
@@ -241,9 +275,10 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
)
def main(dry_run: bool, repo: str | None, verify: bool) -> None:
"""Sync documentation to the Gitea wiki via Git."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
try:
token = get_ci_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if repo is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
@@ -293,6 +328,9 @@ def main(dry_run: bool, repo: str | None, verify: bool) -> None:
# Verification
if verify and not dry_run:
if pushed:
click.echo(_("Waiting 5s for Gitea to process pushed commits..."))
time.sleep(5)
click.echo(_("\nVerifying wiki pages..."))
# Re-clone to verify
verify_dir = Path(tmpdir) / "verify"
@@ -301,8 +339,8 @@ def main(dry_run: bool, repo: str | None, verify: bool) -> None:
raise click.ClickException(_("Wiki verification failed — could not clone wiki"))
failures = 0
for _file_path, page_title in sorted(mapping.items()):
wiki_filename = page_title.replace(" ", "-") + ".md"
if (verify_dir / wiki_filename).exists():
fname = wiki_filename(page_title)
if (verify_dir / fname).exists():
click.echo(_(" OK: {title}", title=page_title))
else:
click.echo(_(" FAIL: {title} — page not found in wiki!", title=page_title))
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Resolve and validate the git tag to deploy.
Shared between staging and production deployments. Ensures a concrete
git tag is used never a moving branch ref so deployments are
reproducible and rollback-friendly.
Usage in workflows::
# Production (tag required)
python -m devx.ci.validate_deploy_ref --tag "$TAG" --github-output
# Staging force-deploy (tag required)
python -m devx.ci.validate_deploy_ref --tag "$TAG" --github-output
# Staging PR-triggered (PR SHA is already concrete, no tag needed)
python -m devx.ci.validate_deploy_ref --allow-empty --github-output
Writes ``deploy-ref=<tag>`` to ``$GITHUB_OUTPUT`` when ``--github-output``
is passed, otherwise prints the ref to stdout.
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import sys
import click
from devx.i18n import _
@click.command()
@click.option("--tag", default="", help=_("Git tag to deploy (e.g. v0.28.1)."))
@click.option(
"--allow-empty",
is_flag=True,
help=_("Allow empty tag (PR mode where SHA is concrete)."),
)
@click.option(
"--github-output",
is_flag=True,
help=_("Write deploy-ref to $GITHUB_OUTPUT file."),
)
def main(tag: str, allow_empty: bool, github_output: bool) -> None:
"""Resolve and validate the deploy ref, exiting non-zero on failure."""
if not tag:
if not allow_empty:
click.echo(
"::error::No tag specified. Deployments require a concrete git tag "
"(e.g. v0.28.1). Use --allow-empty only for PR-triggered staging deploys "
"where the checkout SHA is already concrete.",
err=True,
)
sys.exit(1)
ref = ""
click.echo("No tag specified — using checkout ref (PR mode).")
else:
result = subprocess.run( # nosec B603, B607
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
click.echo(f"::error::Tag '{tag}' does not exist in the repository.", err=True)
sys.exit(1)
ref = tag
commit = result.stdout.strip()[:8]
click.echo(f"Deploying tag: {tag} (commit {commit})")
if github_output:
github_output_path = os.environ.get("GITHUB_OUTPUT")
if not github_output_path:
click.echo("::error::GITHUB_OUTPUT environment variable not set.", err=True)
sys.exit(1)
with open(github_output_path, "a") as f:
f.write(f"deploy-ref={ref}\n")
else:
click.echo(ref)
if __name__ == "__main__": # pragma: no cover
main()
+6 -5
View File
@@ -40,7 +40,6 @@ Usage::
from __future__ import annotations
import json
import os
import shutil
import subprocess # nosec B404
from typing import Any
@@ -49,6 +48,7 @@ import click
from devx.config import GITEA_API_URL
from devx.i18n import _
from devx.tokens import get_ci_token
class TeaCLIError(Exception):
@@ -56,10 +56,10 @@ class TeaCLIError(Exception):
def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL.
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or CI_GITEA_TOKEN is not set.
Skips silently if tea is not installed or no token is set.
Used by CI scripts (publish, notify_failure) that need tea login but
run in containerized environments where ``make setup`` was not called.
@@ -69,8 +69,9 @@ def configure_tea_login(login_name: str = "devx") -> None:
click.echo(_("tea not installed — skipping login configuration."))
return
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
try:
token = get_ci_token()
except click.ClickException:
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
return
+56 -12
View File
@@ -39,6 +39,9 @@
# 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_DOC_COVERAGE_STRICT — fail on missing docs (default: 0)
# DEVX_DOC_VERSIONS_PKG — package name for version ref checks (default: auto)
# DEVX_VALE_LEVEL — vale alert threshold (default: warning)
DEVX_PYTHON ?= python3
DEVX_PR_BASE ?= master
@@ -52,15 +55,18 @@ DEVX_GITEA_PYPI_ORG ?= oblachno-oss
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
DEVX_WORKFLOW_DIR ?= .gitea/workflows
DEVX_DOCKERFILE_PATHS ?= docker
DEVX_VALE_LEVEL ?= warning
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
_TOKEN="$$CI_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
if [ -n "$$_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip
# ── Virtual environment management ────────────────────────────────────────────
@@ -192,12 +198,14 @@ devx-pr-rebase:
# ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other
# private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env.
# private packages. In CI, CI_GITEA_API_TOKEN is set as a secret. Locally, DEVELOPER_GITEA_API_TOKEN or CI_GITEA_TOKEN can be used.
devx-configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
@if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
_TOKEN="$$CI_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$_TOKEN" ]; then echo "[configure-gitea-pypi] Gitea API token not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (token present)."
# Create .env from .env.example if it doesn't exist
devx-env:
@@ -264,7 +272,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
# Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
# Requires: CI_GITEA_API_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"; \
@@ -304,7 +312,7 @@ devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
# ── Quality checks ────────────────────────────────────────────────────────────
@@ -328,10 +336,46 @@ devx-check-docs:
devx-check-doc-versions:
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root .
# Run Vale prose linter on docs and README
# Documentation coverage — checks that all modules/scripts/CLI commands
# are documented. Fails if any are missing when DEVX_DOC_COVERAGE_STRICT=1.
devx-doc-coverage:
@$(DEVX_PYTHON) -m devx.ci.doc_coverage $(if $(filter 1,$(DEVX_DOC_COVERAGE_STRICT)),--fail-on-missing)
# All-in-one documentation gate: coverage + stale refs + structural lint +
# version refs + prose lint. Use in CI and pre-commit as a single step
# instead of 5+ separate steps.
#
# Configuration via environment variables (set in Makefile before include
# or in CI env):
# DEVX_DOC_COVERAGE_STRICT=1 — fail on missing docs (recommended)
# DEVX_DOC_VERSIONS_PKG=<pkg> — enable version ref checks for a named package
# DEVX_VALE_LEVEL=<level> — vale alert threshold (error, warning, suggestion)
# default: warning (catches weasel words, unlabeled
# code blocks, etc. — not just spelling errors)
devx-docs-check: devx-doc-coverage devx-check-docs
@$(DEVX_PYTHON) -m devx.ci.lint_docs --root .
@if [ -n "$(DEVX_DOC_VERSIONS_PKG)" ]; then \
$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . --package $(DEVX_DOC_VERSIONS_PKG); \
elif $(DEVX_PYTHON) -c "import importlib.util,sys; sys.exit(0 if any(importlib.util.find_spec(p) for p in ['devx','grm','oblachno_infra']) else 1)" 2>/dev/null; then \
$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . 2>/dev/null || true; \
fi
@export PATH="$$HOME/.local/bin:$$PATH" && \
if ! command -v vale >/dev/null 2>&1; then \
echo "[devx-docs-check] vale not installed — skipping prose lint (install with 'make install-tools')"; \
else \
vale sync >/dev/null 2>&1 || true; \
vale --minAlertLevel=$(DEVX_VALE_LEVEL) docs/ AGENTS.md README.md; \
fi
# Run Vale prose linter on docs and README (skips if vale not installed)
# Legacy target — use devx-docs-check for the full documentation gate.
devx-vale:
@export PATH="$$HOME/.local/bin:$$PATH" && \
vale --minAlertLevel=error docs/ AGENTS.md README.md
if ! command -v vale >/dev/null 2>&1; then \
echo "[devx-vale] vale not installed — skipping (install with 'make install-tools')"; \
else \
vale --minAlertLevel=error docs/ AGENTS.md README.md; \
fi
# Verify test suite timing
devx-check-test-speed:
+6 -2
View File
@@ -30,6 +30,7 @@ import click
import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3
@@ -86,7 +87,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
return total
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
"""Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default.
@@ -142,7 +143,10 @@ def main(
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "")
try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+6 -2
View File
@@ -22,7 +22,7 @@ Usage::
Environment variables:
GITEA_URL Base URL of the Gitea instance.
CI_GITEA_TOKEN API token with repo access.
CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index).
@@ -45,6 +45,7 @@ import requests
from devx.config import REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_ci_token
POLL_INTERVAL = 10
@@ -164,7 +165,10 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
try:
token = get_ci_token()
except click.ClickException:
token = None
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
+76
View File
@@ -0,0 +1,76 @@
"""Token resolution helpers for devx tools.
Centralizes Gitea/Vikunja token discovery with role-based environment
variable names and backwards compatibility with the legacy
``CI_GITEA_TOKEN`` / ``REVIEW_GITEA_TOKEN`` naming convention.
Roles:
- ``CI_GITEA_API_TOKEN``: CI workflows (read actions, post status, merge, etc.)
- ``REVIEWER_GITEA_API_TOKEN``: PR approval reviews (must be a different user
from the PR author for Gitea to accept the review as an approval)
- ``DEVELOPER_GITEA_API_TOKEN``: local development tools (create-task,
create-pr, setup, etc.)
Fallbacks:
- New role names are checked first.
- Legacy names (``CI_GITEA_TOKEN``, ``REVIEW_GITEA_TOKEN``) are accepted for
backwards compatibility.
- If no role-specific token is set, the generic CI tokens are tried last.
"""
from __future__ import annotations
import os
import click
from devx.i18n import _
# Token environment variable names, in lookup priority order.
CI_TOKEN_NAMES = ["CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"]
REVIEWER_TOKEN_NAMES = [
"REVIEWER_GITEA_API_TOKEN",
# Legacy name used before role-based tokens.
"REVIEW_GITEA_TOKEN",
*CI_TOKEN_NAMES,
]
DEVELOPER_TOKEN_NAMES = ["DEVELOPER_GITEA_API_TOKEN", *CI_TOKEN_NAMES]
VIKUNJA_TOKEN_NAMES = ["VIKUNJA_TOKEN"]
def get_token(*names: str) -> str:
"""Return the first non-empty value from the listed environment variables.
Raises a ``click.ClickException`` if none of the listed variables are set.
"""
for name in names:
token = os.environ.get(name, "").strip()
if token:
return token
raise click.ClickException(
_(
"Gitea API token not set. Set one of: {names}",
names=", ".join(names),
)
)
def get_ci_token() -> str:
"""Resolve the CI Gitea API token."""
return get_token(*CI_TOKEN_NAMES)
def get_reviewer_token() -> str:
"""Resolve the reviewer Gitea API token used for PR approvals."""
return get_token(*REVIEWER_TOKEN_NAMES)
def get_developer_token() -> str:
"""Resolve the developer Gitea API token used for local tooling."""
return get_token(*DEVELOPER_TOKEN_NAMES)
def get_vikunja_token() -> str:
"""Resolve the Vikunja API token."""
return get_token(*VIKUNJA_TOKEN_NAMES)
+5 -2
View File
@@ -8,6 +8,8 @@ import subprocess # nosec B404
import click
from devx.tokens import get_developer_token
def arch_string() -> str:
"""Return the architecture string used by release assets.
@@ -45,8 +47,9 @@ def detect_pr_number() -> int | None:
if branch == "HEAD":
return None
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
try:
token = get_developer_token()
except click.ClickException:
return None
owner = os.environ.get("DEVX_REPO_OWNER", "")
+8 -4
View File
@@ -34,8 +34,8 @@ The manifest file is a JSON list of dicts, each with:
- ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
environment variables, matching the existing CI workflow patterns.
Registry authentication uses ``CI_GITEA_API_TOKEN`` (or legacy ``CI_GITEA_TOKEN``)
and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workflow patterns.
"""
from __future__ import annotations
@@ -49,6 +49,7 @@ from pathlib import Path
import click
from devx.i18n import _
from devx.tokens import get_developer_token
@dataclass
@@ -221,9 +222,12 @@ def push_image(
def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables."""
token = os.environ.get("CI_GITEA_TOKEN", "")
try:
token = get_developer_token()
except click.ClickException:
token = None
username = os.environ.get("CI_GITEA_USERNAME", "")
return username, token
return username, token or ""
@click.command()
+6 -5
View File
@@ -28,12 +28,11 @@ Usage::
--keep 2 \\
--dry-run
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
Authentication uses ``CI_GITEA_API_TOKEN`` environment variable (or legacy ``CI_GITEA_TOKEN``).
"""
from __future__ import annotations
import os
import time
from typing import Any
@@ -42,6 +41,7 @@ import requests
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_developer_token
def list_package_versions(
@@ -187,9 +187,10 @@ def main(
api_url: str | None,
) -> None:
"""Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) from None
if not owner:
owner = REPO_OWNER
if not owner:
+7 -3
View File
@@ -7,8 +7,8 @@ ci-improvement, doc-improvement, workflow-improvement) are created
idempotently via ``ensure_label``.
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
DEVELOPER_GITEA_API_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
DEVELOPER_GITEA_API_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
"""
from __future__ import annotations
@@ -23,6 +23,7 @@ from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_developer_token
def _default_status_checks() -> list[str]:
@@ -175,7 +176,10 @@ def configure_repo(
)
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
"""Configure branch protection and repository settings via the Gitea API."""
token = os.environ.get("CI_GITEA_TOKEN", "")
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
+9 -6
View File
@@ -42,6 +42,7 @@ from devx.config import (
VIKUNJA_PROJECT_ID,
)
from devx.i18n import _
from devx.tokens import get_developer_token, get_vikunja_token
load_dotenv()
@@ -72,9 +73,10 @@ def get_vikunja_task_title(task_id: str) -> str:
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."))
try:
token = get_vikunja_token()
except click.ClickException:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) from None
client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
if not task:
@@ -118,9 +120,10 @@ def create_pr(
),
)
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR."))
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) from None
vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}"
+5 -5
View File
@@ -17,14 +17,13 @@ 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 _
from devx.tokens import get_vikunja_token
load_dotenv()
@@ -39,9 +38,10 @@ load_dotenv()
@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."))
try:
token = get_vikunja_token()
except click.ClickException:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) from None
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
+2 -2
View File
@@ -68,8 +68,8 @@ def detect_package_name(repo_root: Path) -> str | None:
Looks for the first subdirectory under ``src/`` that contains
an ``__init__.py`` file with ``__version__``.
Returns the package directory name (e.g., ``devx``) or ``None`` if
no package is found.
Returns the package directory name (e.g., ``devx``,
``grm``) or ``None`` if no package is found.
"""
src_dir = repo_root / "src"
if not src_dir.is_dir():
+5 -5
View File
@@ -22,14 +22,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr
@@ -48,9 +47,10 @@ def cli(
repo: str | None,
) -> None:
"""Add one or more labels to a pull request (idempotent)."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER
if not repo_owner:
+5 -5
View File
@@ -25,14 +25,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr
@@ -126,9 +125,10 @@ def cli(
repo: str | None,
) -> None:
"""Fetch logs for failed CI jobs on a pull request."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER
if not repo_owner:
+5 -3
View File
@@ -32,6 +32,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL
from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools._shared import detect_pr_number
@@ -41,9 +42,10 @@ def main(pr: int | None) -> None:
"""Rebase a pull request's head branch onto master via Gitea API."""
load_dotenv()
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it."))
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) from None
pr_num = pr or detect_pr_number()
if not pr_num:
+5 -4
View File
@@ -24,7 +24,6 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
from __future__ import annotations
import os
import subprocess # nosec B404
import time
@@ -34,6 +33,7 @@ from dotenv import load_dotenv
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name
load_dotenv()
@@ -139,9 +139,10 @@ def cli(
repo: str | None,
) -> None:
"""Check CI status for a pull request or commit."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER
if not repo_owner:
+7 -5
View File
@@ -20,7 +20,6 @@ Exit codes:
from __future__ import annotations
import os
import subprocess # nosec B404
import click
@@ -29,6 +28,7 @@ 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 _
from devx.tokens import get_vikunja_token
load_dotenv()
@@ -55,8 +55,9 @@ def task_exists(task_id: str) -> bool:
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
try:
token = get_vikunja_token()
except click.ClickException:
return False
client = VikunjaClient(VIKUNJA_API_URL, token)
return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
@@ -84,8 +85,9 @@ def validate(branch: str) -> None:
)
)
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
try:
get_vikunja_token()
except click.ClickException:
click.echo(
_(
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
+8 -5
View File
@@ -16,6 +16,8 @@ from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.tokens import get_developer_token
load_dotenv()
@@ -64,19 +66,20 @@ def _install_ansible_collections(bin_dir: str) -> None:
def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if CI_GITEA_TOKEN is set.
"""Configure tea CLI login from .env if a Gitea token is set.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips if tea is not installed or CI_GITEA_TOKEN is not set.
Skips if tea is not installed or no Gitea token is set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("tea: not installed — run 'make install-tools' to install it.")
return
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.")
try:
token = get_developer_token()
except click.ClickException:
click.echo("tea: Gitea API token not set — skipping login configuration.")
return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
+6 -1
View File
@@ -24,6 +24,8 @@ from pathlib import Path
import click
from devx.tokens import get_developer_token
DEFAULT_VENV = ".venv"
OPT_VENV = "/opt/venv"
FALLBACK_TARGET = "setup-ci"
@@ -67,7 +69,10 @@ def _install_in_image(
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
env = os.environ.copy()
token = env.get("CI_GITEA_TOKEN", "")
try:
token = get_developer_token()
except click.ClickException:
token = None
if token:
username = env.get("CI_GITEA_USERNAME", "emil")
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
+879 -615
View File
@@ -47,13 +47,13 @@
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
},
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"pl": "\nGotowe! Utworzono: {created}, Zaktualizowano: {updated}, Pominięto: {skipped}",
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
"\nDone! Synced: {synced}, Pruned: {pruned}": {
"bg": "",
"de": "",
"en": "\nDone! Synced: {synced}, Pruned: {pruned}",
"pl": "",
"ru": "",
"zh": ""
},
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
@@ -71,6 +71,14 @@
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
},
"\nFAIL: {n} stale version reference(s) found:": {
"bg": "",
"de": "",
"en": "\nFAIL: {n} stale version reference(s) found:",
"pl": "",
"ru": "",
"zh": ""
},
"\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
"bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
@@ -79,6 +87,14 @@
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
},
"\nFixed {n} stale version reference(s).": {
"bg": "",
"de": "",
"en": "\nFixed {n} stale version reference(s).",
"pl": "",
"ru": "",
"zh": ""
},
"\nGenerated {count} badges:": {
"bg": "\nGenerated {count} badges:",
"de": "\nGenerated {count} badges:",
@@ -87,22 +103,6 @@
"ru": "\nGenerated {count} badges:",
"zh": "\nGenerated {count} badges:"
},
"\nIntegrity check FAILED ({count} issues):": {
"bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):",
"en": "\nIntegrity check FAILED ({count} issues):",
"pl": "\nKontrola integralności NIEUDANA ({count} problemów):",
"ru": "\nIntegrity check FAILED ({count} issues):",
"zh": "\nIntegrity check FAILED ({count} issues):"
},
"\nIntegrity check passed — all {count} pages verified.": {
"bg": "\nIntegrity check passed — all {count} pages verified.",
"de": "\nIntegrity check passed — all {count} pages verified.",
"en": "\nIntegrity check passed — all {count} pages verified.",
"pl": "\nKontrola integralności zakończona pomyślnie — wszystkie {count} stron zweryfikowane.",
"ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified."
},
"\nKeeping {kept}, would delete {count}": {
"bg": "\nKeeping {kept}, would delete {count}",
"de": "\nKeeping {kept}, would delete {count}",
@@ -127,6 +127,22 @@
"ru": "\nMissing documentation:",
"zh": "\nMissing documentation:"
},
"\nNo stale version references found.": {
"bg": "",
"de": "",
"en": "\nNo stale version references found.",
"pl": "",
"ru": "",
"zh": ""
},
"\nPASS: All version references are current.": {
"bg": "",
"de": "",
"en": "\nPASS: All version references are current.",
"pl": "",
"ru": "",
"zh": ""
},
"\nResult: {status}": {
"bg": "\nResult: {status}",
"de": "\nResult: {status}",
@@ -151,13 +167,13 @@
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
},
"\nRunning full wiki integrity check...": {
"bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...",
"en": "\nRunning full wiki integrity check...",
"pl": "\nUruchamianie pełnej kontroli integralności wiki...",
"ru": "\nRunning full wiki integrity check...",
"zh": "\nRunning full wiki integrity check..."
"\nRun with --fix to auto-update version references.": {
"bg": "",
"de": "",
"en": "\nRun with --fix to auto-update version references.",
"pl": "",
"ru": "",
"zh": ""
},
"\nTag → Commit alignment:": {
"bg": "\nTag → Commit alignment:",
@@ -183,29 +199,21 @@
"ru": "\nUser-facing changes ({count}):",
"zh": "\nUser-facing changes ({count}):"
},
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"pl": "\nWeryfikacja NIEUDANA: {failures} strona(y) ma pustą lub niezgodną treść!",
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
"\nVerification passed — all wiki pages exist.": {
"bg": "",
"de": "",
"en": "\nVerification passed — all wiki pages exist.",
"pl": "",
"ru": "",
"zh": ""
},
"\nVerification passed — all wiki pages have correct content.": {
"bg": "\nVerification passed — all wiki pages have correct content.",
"de": "\nVerification passed — all wiki pages have correct content.",
"en": "\nVerification passed — all wiki pages have correct content.",
"pl": "\nWeryfikacja zakończona pomyślnie — wszystkie strony wiki mają poprawną treść.",
"ru": "\nVerification passed — all wiki pages have correct content.",
"zh": "\nVerification passed — all wiki pages have correct content."
},
"\nVerifying wiki pages have content...": {
"bg": "\nVerifying wiki pages have content...",
"de": "\nVerifying wiki pages have content...",
"en": "\nVerifying wiki pages have content...",
"pl": "\nWeryfikowanie, czy strony wiki mają treść...",
"ru": "\nVerifying wiki pages have content...",
"zh": "\nVerifying wiki pages have content..."
"\nVerifying wiki pages...": {
"bg": "",
"de": "",
"en": "\nVerifying wiki pages...",
"pl": "",
"ru": "",
"zh": ""
},
"\nWorkflow-only changes ({count}):": {
"bg": "\nWorkflow-only changes ({count}):",
@@ -351,6 +359,30 @@
"ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}"
},
" - {count} standard labels verified": {
"bg": " - {count} standard labels verified",
"de": " - {count} standard labels verified",
"en": " - {count} standard labels verified",
"pl": " - {count} standard labels verified",
"ru": " - {count} standard labels verified",
"zh": " - {count} standard labels verified"
},
" -> {dir}": {
"en": " -> {dir}",
"bg": " -> {dir}",
"de": " -> {dir}",
"pl": " -> {dir}",
"ru": " -> {dir}",
"zh": " -> {dir}"
},
" ... and {n} more": {
"bg": "",
"de": "",
"en": " ... and {n} more",
"pl": "",
"ru": "",
"zh": ""
},
" Auto-fixed trailing whitespace in {n} files": {
"bg": " Auto-fixed trailing whitespace in {n} files",
"de": " Auto-fixed trailing whitespace in {n} files",
@@ -391,14 +423,6 @@
"ru": " Collecting version...",
"zh": " Collecting version..."
},
" Created: {title}": {
"bg": " Created: {title}",
"de": " Created: {title}",
"en": " Created: {title}",
"pl": " Utworzono: {title}",
"ru": " Created: {title}",
"zh": " Created: {title}"
},
" Deleted: {version}": {
"bg": " Deleted: {version}",
"de": " Deleted: {version}",
@@ -407,13 +431,13 @@
"ru": " Deleted: {version}",
"zh": " Deleted: {version}"
},
" FAIL: {title} — content mismatch or empty!": {
"bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!",
"en": " FAIL: {title} — content mismatch or empty!",
"pl": " BŁĄD: {title} — treść niezgodna lub pusta!",
"ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!"
" FAIL: {title} — page not found in wiki!": {
"bg": "",
"de": "",
"en": " FAIL: {title} — page not found in wiki!",
"pl": "",
"ru": "",
"zh": ""
},
" FAILED to delete: {version}": {
"bg": " FAILED to delete: {version}",
@@ -423,6 +447,14 @@
"ru": " FAILED to delete: {version}",
"zh": " FAILED to delete: {version}"
},
" Fixed {fixes} version ref(s) in {file}": {
"bg": "",
"de": "",
"en": " Fixed {fixes} version ref(s) in {file}",
"pl": "",
"ru": "",
"zh": ""
},
" Generated: {path}": {
"bg": " Generated: {path}",
"de": " Generated: {path}",
@@ -479,13 +511,13 @@
"ru": " OK: {script}",
"zh": " OK: {script}"
},
" OK: {title} ({chars} chars)": {
"bg": " OK: {title} ({chars} chars)",
"de": " OK: {title} ({chars} chars)",
"en": " OK: {title} ({chars} chars)",
"pl": " OK: {title} ({chars} znaków)",
"ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)"
" OK: {title}": {
"bg": "",
"de": "",
"en": " OK: {title}",
"pl": "",
"ru": "",
"zh": ""
},
" Package: {pkg}": {
"bg": " Package: {pkg}",
@@ -495,6 +527,14 @@
"ru": " Package: {pkg}",
"zh": " Package: {pkg}"
},
" Pruned: {file} (not in mapping)": {
"bg": "",
"de": "",
"en": " Pruned: {file} (not in mapping)",
"pl": "",
"ru": "",
"zh": ""
},
" Quality checks: {checks}": {
"bg": " Quality checks: {checks}",
"de": " Quality checks: {checks}",
@@ -511,6 +551,22 @@
"ru": " Repo root: {root}",
"zh": " Repo root: {root}"
},
" Run 'make install-checkmake' to install the Makefile linter.": {
"en": " Run 'make install-checkmake' to install the Makefile linter.",
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
},
" Synced: {title} → {file}": {
"bg": "",
"de": "",
"en": " Synced: {title} → {file}",
"pl": "",
"ru": "",
"zh": ""
},
" Test paths: {testpaths}": {
"bg": " Test paths: {testpaths}",
"de": " Test paths: {testpaths}",
@@ -519,13 +575,21 @@
"ru": " Test paths: {testpaths}",
"zh": " Test paths: {testpaths}"
},
" Updated: {title}": {
"bg": " Updated: {title}",
"de": " Updated: {title}",
"en": " Updated: {title}",
"pl": " Zaktualizowano: {title}",
"ru": " Updated: {title}",
"zh": " Updated: {title}"
" WARN: Mapped file {file} is empty, skipping": {
"bg": "",
"de": "",
"en": " WARN: Mapped file {file} is empty, skipping",
"pl": "",
"ru": "",
"zh": ""
},
" WARN: Mapped file {file} not found, skipping": {
"bg": "",
"de": "",
"en": " WARN: Mapped file {file} not found, skipping",
"pl": "",
"ru": "",
"zh": ""
},
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
@@ -615,6 +679,22 @@
"ru": " {name}: {label}={message} ({color})",
"zh": " {name}: {label}={message} ({color})"
},
" {n} long lines found (warnings only)": {
"bg": "",
"de": "",
"en": " {n} long lines found (warnings only)",
"pl": "",
"ru": "",
"zh": ""
},
" {n} orphan docs found (warnings only)": {
"bg": "",
"de": "",
"en": " {n} orphan docs found (warnings only)",
"pl": "",
"ru": "",
"zh": ""
},
" {n} stale docs found (warnings only)": {
"bg": " {n} stale docs found (warnings only)",
"de": " {n} stale docs found (warnings only)",
@@ -623,6 +703,14 @@
"ru": " {n} stale docs found (warnings only)",
"zh": " {n} stale docs found (warnings only)"
},
" {tool}: found at {path}": {
"en": " {tool}: found at {path}",
"bg": " {tool}: намерен на {path}",
"de": " {tool}: gefunden unter {path}",
"pl": " {tool}: znaleziono w {path}",
"ru": " {tool}: найден в {path}",
"zh": " {tool}: 在 {path} 找到"
},
" {version} (created: {created})": {
"bg": " {version} (created: {created})",
"de": " {version} (created: {created})",
@@ -727,6 +815,22 @@
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
},
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
"Automated CI commit (badge) — skipping post-merge jobs.": {
"bg": "Automated CI commit (badge) — skipping post-merge jobs.",
"de": "Automated CI commit (badge) — skipping post-merge jobs.",
"en": "Automated CI commit (badge) — skipping post-merge jobs.",
"pl": "Automated CI commit (badge) — skipping post-merge jobs.",
"ru": "Automated CI commit (badge) — skipping post-merge jobs.",
"zh": "Automated CI commit (badge) — skipping post-merge jobs."
},
"Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
"bg": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"de": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
@@ -775,6 +879,22 @@
"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 already up-to-date with origin/master.": {
"bg": "Branch is already up-to-date with origin/master.",
"de": "Branch is already up-to-date with origin/master.",
"en": "Branch is already up-to-date with origin/master.",
"pl": "Branch is already up-to-date with origin/master.",
"ru": "Branch is already up-to-date with origin/master.",
"zh": "Branch is already up-to-date with origin/master."
},
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
},
"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",
@@ -783,6 +903,14 @@
"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 is {count} commit(s) behind master. Rebasing...": {
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
"de": "Branch is {count} commit(s) behind master. Rebasing...",
"en": "Branch is {count} commit(s) behind master. Rebasing...",
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
},
"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)",
@@ -831,6 +959,14 @@
"ru": "CI checks failed.",
"zh": "CI checks failed."
},
"Gitea API token not set. Set one of: {names}": {
"bg": "Gitea API token not set. Set one of: {names}",
"de": "Gitea API token not set. Set one of: {names}",
"en": "Gitea API token not set. Set one of: {names}",
"pl": "Gitea API token not set. Set one of: {names}",
"ru": "Gitea API token not set. Set one of: {names}",
"zh": "Gitea API token not set. Set one of: {names}"
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
@@ -847,6 +983,14 @@
"ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set."
},
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
@@ -863,6 +1007,22 @@
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
},
"Cannot read __version__ from src/{pkg}/__init__.py — skipping.": {
"bg": "",
"de": "",
"en": "Cannot read __version__ from src/{pkg}/__init__.py — skipping.",
"pl": "",
"ru": "",
"zh": ""
},
"Cannot rebase: not on a branch (detached HEAD).": {
"bg": "Cannot rebase: not on a branch (detached HEAD).",
"de": "Cannot rebase: not on a branch (detached HEAD).",
"en": "Cannot rebase: not on a branch (detached HEAD).",
"pl": "Cannot rebase: not on a branch (detached HEAD).",
"ru": "Cannot rebase: not on a branch (detached HEAD).",
"zh": "Cannot rebase: not on a branch (detached HEAD)."
},
"Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...",
@@ -871,6 +1031,14 @@
"ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..."
},
"Checking code block languages...": {
"bg": "",
"de": "",
"en": "Checking code block languages...",
"pl": "",
"ru": "",
"zh": ""
},
"Checking docs structure...": {
"bg": "Checking docs structure...",
"de": "Checking docs structure...",
@@ -895,6 +1063,14 @@
"ru": "Checking for TODO/FIXME markers...",
"zh": "Checking for TODO/FIXME markers..."
},
"Checking for orphan docs...": {
"bg": "",
"de": "",
"en": "Checking for orphan docs...",
"pl": "",
"ru": "",
"zh": ""
},
"Checking for stale docs...": {
"bg": "Checking for stale docs...",
"de": "Checking for stale docs...",
@@ -919,6 +1095,22 @@
"ru": "Checking internal links...",
"zh": "Checking internal links..."
},
"Checking line length...": {
"bg": "",
"de": "",
"en": "Checking line length...",
"pl": "",
"ru": "",
"zh": ""
},
"Checking max heading depth...": {
"bg": "",
"de": "",
"en": "Checking max heading depth...",
"pl": "",
"ru": "",
"zh": ""
},
"Checking required files...": {
"bg": "Checking required files...",
"de": "Checking required files...",
@@ -927,6 +1119,14 @@
"ru": "Checking required files...",
"zh": "Checking required files..."
},
"Checking single H1 per file...": {
"bg": "",
"de": "",
"en": "Checking single H1 per file...",
"pl": "",
"ru": "",
"zh": ""
},
"Checking status for PR #{pr_number}...": {
"bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...",
@@ -943,6 +1143,30 @@
"ru": "Checking trailing whitespace...",
"zh": "Checking trailing whitespace..."
},
"Checking version references for {pkg} (current: v{version})": {
"bg": "",
"de": "",
"en": "Checking version references for {pkg} (current: v{version})",
"pl": "",
"ru": "",
"zh": ""
},
"Cloned existing wiki.": {
"bg": "",
"de": "",
"en": "Cloned existing wiki.",
"pl": "",
"ru": "",
"zh": ""
},
"Cloning wiki repo...": {
"bg": "",
"de": "",
"en": "Cloning wiki repo...",
"pl": "",
"ru": "",
"zh": ""
},
"Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}",
@@ -967,6 +1191,14 @@
"ru": "Commit: {sha}",
"zh": "Commit: {sha}"
},
"Committing and pushing...": {
"bg": "",
"de": "",
"en": "Committing and pushing...",
"pl": "",
"ru": "",
"zh": ""
},
"Comparing {base}..{head} ({count} files changed)": {
"bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)",
@@ -1015,6 +1247,14 @@
"ru": "Configuring tea login '{name}' for {url}...",
"zh": "Configuring tea login '{name}' for {url}..."
},
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
},
"Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
@@ -1031,6 +1271,14 @@
"ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}."
},
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
},
"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.",
@@ -1119,6 +1367,14 @@
"ru": "Dependencies must have documentation comments.",
"zh": "Dependencies must have documentation comments."
},
"Directory to scan (default: tests/integration). Can be repeated.": {
"en": "Directory to scan (default: tests/integration). Can be repeated.",
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
},
"Docker daemon already running": {
"bg": "Докер демонът вече работи",
"de": "Docker-Daemon läuft bereits",
@@ -1207,6 +1463,22 @@
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
},
"Ensuring standard labels...": {
"bg": "Ensuring standard labels...",
"de": "Ensuring standard labels...",
"en": "Ensuring standard labels...",
"pl": "Ensuring standard labels...",
"ru": "Ensuring standard labels...",
"zh": "Ensuring standard labels..."
},
"FAIL: Could not clone wiki for verification.": {
"bg": "",
"de": "",
"en": "FAIL: Could not clone wiki for verification.",
"pl": "",
"ru": "",
"zh": ""
},
"FAIL: {n} documentation issues found:": {
"bg": "FAIL: {n} documentation issues found:",
"de": "FAIL: {n} documentation issues found:",
@@ -1263,6 +1535,30 @@
"ru": "Failed to list versions for {name}: {error}",
"zh": "Failed to list versions for {name}: {error}"
},
"Failed to push release commit after 3 attempts. Manual intervention required.": {
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
},
"Failed to start ssh-agent: {error}": {
"en": "Failed to start ssh-agent: {error}",
"bg": "Неуспешно стартиране на ssh-agent: {error}",
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
"pl": "Nie udało się uruchomić ssh-agent: {error}",
"ru": "Не удалось запустить ssh-agent: {error}",
"zh": "启动 ssh-agent 失败: {error}"
},
"Fetch failed: {error}": {
"bg": "Fetch failed: {error}",
"de": "Fetch failed: {error}",
"en": "Fetch failed: {error}",
"pl": "Fetch failed: {error}",
"ru": "Fetch failed: {error}",
"zh": "Fetch failed: {error}"
},
"Fetching logs for PR #{pr_number}...": {
"bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...",
@@ -1271,13 +1567,29 @@
"ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..."
},
"Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.",
"en": "Found {count} existing wiki pages.",
"pl": "Znaleziono {count} istniejących stron wiki.",
"ru": "Found {count} existing wiki pages.",
"zh": "Found {count} existing wiki pages."
"Fetching origin/master...": {
"bg": "Fetching origin/master...",
"de": "Fetching origin/master...",
"en": "Fetching origin/master...",
"pl": "Fetching origin/master...",
"ru": "Fetching origin/master...",
"zh": "Fetching origin/master..."
},
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
},
"Force-pushing...": {
"bg": "Force-pushing...",
"de": "Force-pushing...",
"en": "Force-pushing...",
"pl": "Force-pushing...",
"ru": "Force-pushing...",
"zh": "Force-pushing..."
},
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
@@ -1295,6 +1607,14 @@
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"Found {count} unsafe identity check(s) in integration tests.": {
"en": "Found {count} unsafe identity check(s) in integration tests.",
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
},
"Found {count} version(s):": {
"bg": "Found {count} version(s):",
"de": "Found {count} version(s):",
@@ -1519,6 +1839,14 @@
"ru": "Linting documentation in {root}...",
"zh": "Linting documentation in {root}..."
},
"Login to {registry} failed: {error}": {
"en": "Login to {registry} failed: {error}",
"bg": "Влизането в {registry} не успя: {error}",
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
"pl": "Logowanie do {registry} nie powiodło się: {error}",
"ru": "Ошибка входа в {registry}: {error}",
"zh": "登录 {registry} 失败: {error}"
},
"Manifest file not found: {path}": {
"bg": "Manifest file not found: {path}",
"de": "Manifest file not found: {path}",
@@ -1535,22 +1863,6 @@
"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.": {
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"pl": "Mapowany plik {file} jest pusty. Zaktualizuj treść lub usuń z mapping.json.",
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
},
"Mapped file {file} not found. Update mapping.json or create the file.": {
"bg": "Mapped file {file} not found. Update mapping.json or create the file.",
"de": "Mapped file {file} not found. Update mapping.json or create the file.",
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
"pl": "Mapowany plik {file} nie znaleziony. Zaktualizuj mapping.json lub utwórz plik.",
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
},
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
@@ -1631,6 +1943,14 @@
"ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}."
},
"No Python package found under src/ — skipping version check.": {
"bg": "",
"de": "",
"en": "No Python package found under src/ — skipping version check.",
"pl": "",
"ru": "",
"zh": ""
},
"No badge SVG files generated": {
"bg": "No badge SVG files generated",
"de": "No badge SVG files generated",
@@ -1647,6 +1967,14 @@
"ru": "No badge URLs found to update — README already up to date",
"zh": "No badge URLs found to update — README already up to date"
},
"No badge changes — skipping commit": {
"bg": "",
"de": "",
"en": "No badge changes — skipping commit",
"pl": "",
"ru": "",
"zh": ""
},
"No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.",
@@ -1655,6 +1983,14 @@
"ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}."
},
"No changes to sync — wiki is up to date.": {
"bg": "",
"de": "",
"en": "No changes to sync — wiki is up to date.",
"pl": "",
"ru": "",
"zh": ""
},
"No failed jobs.": {
"bg": "No failed jobs.",
"de": "No failed jobs.",
@@ -1687,6 +2023,14 @@
"ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'."
},
"No push needed (no changes or push failed).": {
"bg": "",
"de": "",
"en": "No push needed (no changes or push failed).",
"pl": "",
"ru": "",
"zh": ""
},
"No staged changes — version and changelog already up to date.": {
"bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.",
@@ -1760,12 +2104,36 @@
"zh": "No workflow runs found for SHA {sha}."
},
"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.",
"bg": "Забележка: Само-одобрението не е разрешено. Публикуване на COMMENT вместо това.",
"de": "Hinweis: Selbstgenehmigung nicht erlaubt. COMMENT wird stattdessen gesendet.",
"en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone. Publikowanie COMMENT zamiast tego.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
"ru": "Примечание: Самоодобрение не разрешено. Публикация COMMENT вместо этого.",
"zh": "注意:不允许自我批准。改为发布 COMMENT。"
},
"Note: Self-approval not allowed with reviewer token. Retrying with CI token.": {
"bg": "Забележка: Само-одобрението не е разрешено с тоукън на рецензента. Повторен опит с CI тоукън.",
"de": "Hinweis: Selbstgenehmigung mit Reviewer-Token nicht erlaubt. Wiederholung mit CI-Token.",
"en": "Note: Self-approval not allowed with reviewer token. Retrying with CI token.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone tokenem recenzenta. Ponawianie tokenem CI.",
"ru": "Примечание: Самоодобрение токеном ревьюера не разрешено. Повторная попытка с CI токеном.",
"zh": "注意:不允许使用审阅者令牌进行自我批准。正在使用 CI 令牌重试。"
},
"Note: CI token also cannot approve. Posting COMMENT instead.": {
"bg": "Забележка: CI тоукънът също не може да одобри. Публикуване на COMMENT вместо това.",
"de": "Hinweis: CI-Token kann ebenfalls nicht genehmigen. COMMENT wird stattdessen gesendet.",
"en": "Note: CI token also cannot approve. Posting COMMENT instead.",
"pl": "Uwaga: Token CI również nie może zatwierdzić. Publikowanie COMMENT zamiast tego.",
"ru": "Примечание: CI токен также не может одобрить. Публикация COMMENT вместо этого.",
"zh": "注意:CI 令牌也无法批准。改为发布 COMMENT。"
},
"Nothing to push.": {
"bg": "Nothing to push.",
"de": "Nothing to push.",
"en": "Nothing to push.",
"pl": "Nothing to push.",
"ru": "Nothing to push.",
"zh": "Nothing to push."
},
"Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)",
@@ -1871,6 +2239,14 @@
"ru": "PASSED: {pair}",
"zh": "PASSED: {pair}"
},
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
},
"PR already exists: #{index} — {url}": {
"bg": "PR вече съществува: #{index} — {url}",
"de": "PR existiert bereits: #{index} — {url}",
@@ -2047,6 +2423,14 @@
"ru": "Publishing release {tag}...",
"zh": "Publishing release {tag}..."
},
"Push attempt {n}/3 failed: {err}": {
"bg": "Push attempt {n}/3 failed: {err}",
"de": "Push attempt {n}/3 failed: {err}",
"en": "Push attempt {n}/3 failed: {err}",
"pl": "Push attempt {n}/3 failed: {err}",
"ru": "Push attempt {n}/3 failed: {err}",
"zh": "Push attempt {n}/3 failed: {err}"
},
"Push failed for {tag}: {error}": {
"bg": "Push failed for {tag}: {error}",
"de": "Push failed for {tag}: {error}",
@@ -2055,6 +2439,14 @@
"ru": "Push failed for {tag}: {error}",
"zh": "Push failed for {tag}: {error}"
},
"Push failed: {error}": {
"bg": "",
"de": "",
"en": "Push failed: {error}",
"pl": "",
"ru": "",
"zh": ""
},
"Pushed README update with badge SHA {sha}": {
"bg": "Pushed README update with badge SHA {sha}",
"de": "Pushed README update with badge SHA {sha}",
@@ -2071,6 +2463,14 @@
"ru": "Pushed release commit to master.",
"zh": "Pushed release commit to master."
},
"Pushed {branch} to origin.": {
"bg": "Pushed {branch} to origin.",
"de": "Pushed {branch} to origin.",
"en": "Pushed {branch} to origin.",
"pl": "Pushed {branch} to origin.",
"ru": "Pushed {branch} to origin.",
"zh": "Pushed {branch} to origin."
},
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
@@ -2087,6 +2487,46 @@
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"Rebase attempt {n}/3 failed: {err}": {
"bg": "Rebase attempt {n}/3 failed: {err}",
"de": "Rebase attempt {n}/3 failed: {err}",
"en": "Rebase attempt {n}/3 failed: {err}",
"pl": "Rebase attempt {n}/3 failed: {err}",
"ru": "Rebase attempt {n}/3 failed: {err}",
"zh": "Rebase attempt {n}/3 failed: {err}"
},
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
},
"Rebase failed with HTTP {status}: {message}": {
"bg": "Rebase failed with HTTP {status}: {message}",
"de": "Rebase failed with HTTP {status}: {message}",
"en": "Rebase failed with HTTP {status}: {message}",
"pl": "Rebase failed with HTTP {status}: {message}",
"ru": "Rebase failed with HTTP {status}: {message}",
"zh": "Rebase failed with HTTP {status}: {message}"
},
"Rebase successful.": {
"bg": "Rebase successful.",
"de": "Rebase successful.",
"en": "Rebase successful.",
"pl": "Rebase successful.",
"ru": "Rebase successful.",
"zh": "Rebase successful."
},
"Rebasing PR #{pr} via Gitea API...": {
"bg": "Rebasing PR #{pr} via Gitea API...",
"de": "Rebasing PR #{pr} via Gitea API...",
"en": "Rebasing PR #{pr} via Gitea API...",
"pl": "Rebasing PR #{pr} via Gitea API...",
"ru": "Rebasing PR #{pr} via Gitea API...",
"zh": "Rebasing PR #{pr} via Gitea API..."
},
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
@@ -2127,14 +2567,6 @@
"ru": "Release commit — skipping all post-merge jobs.",
"zh": "Release commit — skipping all post-merge jobs."
},
"Automated CI commit (badge) — skipping post-merge jobs.": {
"bg": "Automated CI commit (badge) — skipping post-merge jobs.",
"de": "Automated CI commit (badge) — skipping post-merge jobs.",
"en": "Automated CI commit (badge) — skipping post-merge jobs.",
"pl": "Automated CI commit (badge) — skipping post-merge jobs.",
"ru": "Automated CI commit (badge) — skipping post-merge jobs.",
"zh": "Automated CI commit (badge) — skipping post-merge jobs."
},
"Release creation failed: {error}": {
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
@@ -2191,6 +2623,14 @@
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
},
"Required tools missing.": {
"en": "Required tools missing.",
"bg": "Липсват задължителни инструменти.",
"de": "Erforderliche Werkzeuge fehlen.",
"pl": "Brak wymaganych narzędzi.",
"ru": "Отсутствуют обязательные инструменты.",
"zh": "缺少必需的工具。"
},
"Review body must be at least 50 characters.": {
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
@@ -2279,6 +2719,30 @@
"ru": "Running: {scenario} on {platform}",
"zh": "Running: {scenario} on {platform}"
},
"SSH key set up successfully": {
"en": "SSH key set up successfully",
"bg": "SSH ключът е настроен успешно",
"de": "SSH-Schlüssel erfolgreich eingerichtet",
"pl": "Klucz SSH skonfigurowany pomyślnie",
"ru": "SSH-ключ успешно настроен",
"zh": "SSH 密钥设置成功"
},
"SSH key setup skipped (no key provided)": {
"en": "SSH key setup skipped (no key provided)",
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
"zh": "SSH 密钥设置已跳过(未提供密钥)"
},
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
},
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
@@ -2319,13 +2783,21 @@
"ru": "Synced to latest origin/{branch}",
"zh": "Synced to latest origin/{branch}"
},
"Syncing {count} documentation pages to wiki...": {
"bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...",
"en": "Syncing {count} documentation pages to wiki...",
"pl": "Synchronizowanie {count} stron dokumentacji do wiki...",
"ru": "Syncing {count} documentation pages to wiki...",
"zh": "Syncing {count} documentation pages to wiki..."
"Syncing files...": {
"bg": "",
"de": "",
"en": "Syncing files...",
"pl": "",
"ru": "",
"zh": ""
},
"Syncing {count} documentation pages to wiki via Git...": {
"bg": "",
"de": "",
"en": "Syncing {count} documentation pages to wiki via Git...",
"pl": "",
"ru": "",
"zh": ""
},
"Tag consistency check failed.": {
"bg": "Tag consistency check failed.",
@@ -2439,6 +2911,14 @@
"ru": "Updated badge URLs in {filename}",
"zh": "Updated badge URLs in {filename}"
},
"Updated documentation version references to v{version}": {
"bg": "",
"de": "",
"en": "Updated documentation version references to v{version}",
"pl": "",
"ru": "",
"zh": ""
},
"Updated version in {init}": {
"bg": "Updated version in {init}",
"de": "Updated version in {init}",
@@ -2455,6 +2935,14 @@
"ru": "Updated {changelog_file}",
"zh": "Updated {changelog_file}"
},
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
},
"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.",
@@ -2511,6 +2999,38 @@
"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 是否正确。"
},
"WARN: .venv has Python {version}, but >={req} is required.": {
"en": "WARN: .venv has Python {version}, but >={req} is required.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
},
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
},
"WARN: Could not determine Python version in .venv.": {
"en": "WARN: Could not determine Python version in .venv.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
},
"WARN: Could not parse Python version '{version}'.": {
"en": "WARN: Could not parse Python version '{version}'.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
"zh": "警告: 无法解析 Python 版本 '{version}'。"
},
"WARNING: --skip-tests passed — skipping test verification.": {
"bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "WARNING: --skip-tests passed — skipping test verification.",
@@ -2527,22 +3047,6 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
},
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
},
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
},
"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.",
@@ -2551,6 +3055,30 @@
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"WARNING: Version badge shows stale version (expected v{version}) — regenerating": {
"bg": "",
"de": "",
"en": "WARNING: Version badge shows stale version (expected v{version}) — regenerating",
"pl": "",
"ru": "",
"zh": ""
},
"WARNING: check_doc_versions --fix failed (rc={rc}): {err}": {
"bg": "",
"de": "",
"en": "WARNING: check_doc_versions --fix failed (rc={rc}): {err}",
"pl": "",
"ru": "",
"zh": ""
},
"Waiting 5s for Gitea to process pushed commits...": {
"bg": "",
"de": "",
"en": "Waiting 5s for Gitea to process pushed commits...",
"pl": "",
"ru": "",
"zh": ""
},
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
@@ -2615,21 +3143,37 @@
"ru": "Warning: repo-level runners query returned HTTP {status}",
"zh": "Warning: repo-level runners query returned HTTP {status}"
},
"Wiki integrity check failed — {count} issue(s)": {
"bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)",
"en": "Wiki integrity check failed — {count} issue(s)",
"pl": "Kontrola integralności wiki nie powiodła się — {count} problem(ów)",
"ru": "Wiki integrity check failed — {count} issue(s)",
"zh": "Wiki integrity check failed — {count} issue(s)"
"Wiki repo not found or empty — initializing fresh.": {
"bg": "",
"de": "",
"en": "Wiki repo not found or empty — initializing fresh.",
"pl": "",
"ru": "",
"zh": ""
},
"Wiki verification failed — {failures} page(s) empty or mismatched": {
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
"en": "Wiki verification failed — {failures} page(s) empty or mismatched",
"pl": "Weryfikacja wiki nie powiodła się — {failures} strona(y) pusta lub niezgodna",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
"Wiki synced successfully.": {
"bg": "",
"de": "",
"en": "Wiki synced successfully.",
"pl": "",
"ru": "",
"zh": ""
},
"Wiki verification failed — could not clone wiki": {
"bg": "",
"de": "",
"en": "Wiki verification failed — could not clone wiki",
"pl": "",
"ru": "",
"zh": ""
},
"Wiki verification failed — {failures} page(s) missing": {
"bg": "",
"de": "",
"en": "Wiki verification failed — {failures} page(s) missing",
"pl": "",
"ru": "",
"zh": ""
},
"Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
@@ -2639,6 +3183,14 @@
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
},
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
},
"[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",
@@ -2647,6 +3199,30 @@
"ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented"
},
"[check-deps] All core tools present.": {
"en": "[check-deps] All core tools present.",
"bg": "[check-deps] Всички основни инструменти са налични.",
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
"ru": "[check-deps] Все основные инструменты доступны.",
"zh": "[check-deps] 所有核心工具均已就绪。"
},
"[check-deps] Verifying tools...": {
"en": "[check-deps] Verifying tools...",
"bg": "[check-deps] Проверка на инструментите...",
"de": "[check-deps] Werkzeuge werden überprüft...",
"pl": "[check-deps] Sprawdzanie narzędzi...",
"ru": "[check-deps] Проверка инструментов...",
"zh": "[check-deps] 正在验证工具..."
},
"[check-deps] Virtualenv .venv ready (Python {version}).": {
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
},
"[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",
@@ -2671,6 +3247,46 @@
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
},
"[docker-login] Logged in to {registry}.": {
"en": "[docker-login] Logged in to {registry}.",
"bg": "[docker-login] Влязъл в {registry}.",
"de": "[docker-login] Angemeldet bei {registry}.",
"pl": "[docker-login] Zalogowano do {registry}.",
"ru": "[docker-login] Выполнен вход в {registry}.",
"zh": "[docker-login] 已登录到 {registry}。"
},
"[docker-login] Login to {registry} failed (continuing).": {
"en": "[docker-login] Login to {registry} failed (continuing).",
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
},
"[docker-login] Skipping {registry} (token {env} not set).": {
"en": "[docker-login] Skipping {registry} (token {env} not set).",
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
},
"[dry-run] No changes pushed.": {
"bg": "",
"de": "",
"en": "[dry-run] No changes pushed.",
"pl": "",
"ru": "",
"zh": ""
},
"[dry-run] Would commit and push wiki changes": {
"bg": "",
"de": "",
"en": "[dry-run] Would commit and push wiki changes",
"pl": "",
"ru": "",
"zh": ""
},
"[dry-run] Would commit: release: v{version} [skip ci]": {
"bg": "[dry-run] Would commit: release: v{version} [skip ci]",
"de": "[dry-run] Would commit: release: v{version} [skip ci]",
@@ -2703,13 +3319,13 @@
"ru": "[dry-run] Would push commit to master",
"zh": "[dry-run] Would push commit to master"
},
"[dry-run] Would sync page: {title} ({chars} chars)": {
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
"en": "[dry-run] Would sync page: {title} ({chars} chars)",
"pl": "[dry-run] Zsynchronizowano by stronę: {title} ({chars} znaków)",
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
"[dry-run] Would update doc version references via check_doc_versions --fix": {
"bg": "",
"de": "",
"en": "[dry-run] Would update doc version references via check_doc_versions --fix",
"pl": "",
"ru": "",
"zh": ""
},
"[dry-run] Would update {changelog_file}": {
"bg": "[dry-run] Would update {changelog_file}",
@@ -2727,6 +3343,38 @@
"ru": "[dry-run] Would update {init}",
"zh": "[dry-run] Would update {init}"
},
"[tofu-init] Done.": {
"en": "[tofu-init] Done.",
"bg": "[tofu-init] Готово.",
"de": "[tofu-init] Fertig.",
"pl": "[tofu-init] Gotowe.",
"ru": "[tofu-init] Готово.",
"zh": "[tofu-init] 完成。"
},
"[tofu-init] Initializing {dir}...": {
"en": "[tofu-init] Initializing {dir}...",
"bg": "[tofu-init] Инициализиране на {dir}...",
"de": "[tofu-init] Initialisiere {dir}...",
"pl": "[tofu-init] Inicjalizacja {dir}...",
"ru": "[tofu-init] Инициализация {dir}...",
"zh": "[tofu-init] 正在初始化 {dir}..."
},
"[tofu-{mode}] All configurations valid.": {
"en": "[tofu-{mode}] All configurations valid.",
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
"ru": "[tofu-{mode}] Все конфигурации валидны.",
"zh": "[tofu-{mode}] 所有配置有效。"
},
"[tofu-{mode}] Validating OpenTofu configurations...": {
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
},
"[tool.devx] missing required keys: {keys}": {
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
@@ -2879,6 +3527,14 @@
"ru": "tea not installed — skipping login configuration.",
"zh": "tea not installed — skipping login configuration."
},
"tofu command failed in {dir}: {error}": {
"en": "tofu command failed in {dir}: {error}",
"bg": "командата tofu не успя в {dir}: {error}",
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
"ru": "команда tofu не удалась в {dir}: {error}",
"zh": "tofu 命令在 {dir} 中失败: {error}"
},
"unknown": {
"bg": "неизвестен",
"de": "unbekannt",
@@ -2887,286 +3543,6 @@
"ru": "неизвестно",
"zh": "未知"
},
"{file} already exists. Use --force to overwrite.": {
"bg": "{file} already exists. Use --force to overwrite.",
"de": "{file} already exists. Use --force to overwrite.",
"en": "{file} already exists. Use --force to overwrite.",
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
"ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite."
},
"{separator}": {
"bg": "{separator}",
"de": "{separator}",
"en": "{separator}",
"pl": "{separator}",
"ru": "{separator}",
"zh": "{separator}"
},
"Failed to push release commit after 3 attempts. Manual intervention required.": {
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
},
"Push attempt {n}/3 failed: {err}": {
"bg": "Push attempt {n}/3 failed: {err}",
"de": "Push attempt {n}/3 failed: {err}",
"en": "Push attempt {n}/3 failed: {err}",
"pl": "Push attempt {n}/3 failed: {err}",
"ru": "Push attempt {n}/3 failed: {err}",
"zh": "Push attempt {n}/3 failed: {err}"
},
"Rebase attempt {n}/3 failed: {err}": {
"bg": "Rebase attempt {n}/3 failed: {err}",
"de": "Rebase attempt {n}/3 failed: {err}",
"en": "Rebase attempt {n}/3 failed: {err}",
"pl": "Rebase attempt {n}/3 failed: {err}",
"ru": "Rebase attempt {n}/3 failed: {err}",
"zh": "Rebase attempt {n}/3 failed: {err}"
},
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase 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 already up-to-date with origin/master.": {
"bg": "Branch is already up-to-date with origin/master.",
"de": "Branch is already up-to-date with origin/master.",
"en": "Branch is already up-to-date with origin/master.",
"pl": "Branch is already up-to-date with origin/master.",
"ru": "Branch is already up-to-date with origin/master.",
"zh": "Branch is already up-to-date with origin/master."
},
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
},
"Branch is {count} commit(s) behind master. Rebasing...": {
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
"de": "Branch is {count} commit(s) behind master. Rebasing...",
"en": "Branch is {count} commit(s) behind master. Rebasing...",
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
},
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
},
"Cannot rebase: not on a branch (detached HEAD).": {
"bg": "Cannot rebase: not on a branch (detached HEAD).",
"de": "Cannot rebase: not on a branch (detached HEAD).",
"en": "Cannot rebase: not on a branch (detached HEAD).",
"pl": "Cannot rebase: not on a branch (detached HEAD).",
"ru": "Cannot rebase: not on a branch (detached HEAD).",
"zh": "Cannot rebase: not on a branch (detached HEAD)."
},
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
},
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
},
"Fetch failed: {error}": {
"bg": "Fetch failed: {error}",
"de": "Fetch failed: {error}",
"en": "Fetch failed: {error}",
"pl": "Fetch failed: {error}",
"ru": "Fetch failed: {error}",
"zh": "Fetch failed: {error}"
},
"Fetching origin/master...": {
"bg": "Fetching origin/master...",
"de": "Fetching origin/master...",
"en": "Fetching origin/master...",
"pl": "Fetching origin/master...",
"ru": "Fetching origin/master...",
"zh": "Fetching origin/master..."
},
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
},
"Force-pushing...": {
"bg": "Force-pushing...",
"de": "Force-pushing...",
"en": "Force-pushing...",
"pl": "Force-pushing...",
"ru": "Force-pushing...",
"zh": "Force-pushing..."
},
"Nothing to push.": {
"bg": "Nothing to push.",
"de": "Nothing to push.",
"en": "Nothing to push.",
"pl": "Nothing to push.",
"ru": "Nothing to push.",
"zh": "Nothing to push."
},
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
},
"Pushed {branch} to origin.": {
"bg": "Pushed {branch} to origin.",
"de": "Pushed {branch} to origin.",
"en": "Pushed {branch} to origin.",
"pl": "Pushed {branch} to origin.",
"ru": "Pushed {branch} to origin.",
"zh": "Pushed {branch} to origin."
},
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
},
"Rebase failed with HTTP {status}: {message}": {
"bg": "Rebase failed with HTTP {status}: {message}",
"de": "Rebase failed with HTTP {status}: {message}",
"en": "Rebase failed with HTTP {status}: {message}",
"pl": "Rebase failed with HTTP {status}: {message}",
"ru": "Rebase failed with HTTP {status}: {message}",
"zh": "Rebase failed with HTTP {status}: {message}"
},
"Rebase successful.": {
"bg": "Rebase successful.",
"de": "Rebase successful.",
"en": "Rebase successful.",
"pl": "Rebase successful.",
"ru": "Rebase successful.",
"zh": "Rebase successful."
},
"Rebasing PR #{pr} via Gitea API...": {
"bg": "Rebasing PR #{pr} via Gitea API...",
"de": "Rebasing PR #{pr} via Gitea API...",
"en": "Rebasing PR #{pr} via Gitea API...",
"pl": "Rebasing PR #{pr} via Gitea API...",
"ru": "Rebasing PR #{pr} via Gitea API...",
"zh": "Rebasing PR #{pr} via Gitea API..."
},
"Ensuring standard labels...": {
"bg": "Ensuring standard labels...",
"de": "Ensuring standard labels...",
"en": "Ensuring standard labels...",
"pl": "Ensuring standard labels...",
"ru": "Ensuring standard labels...",
"zh": "Ensuring standard labels..."
},
" - {count} standard labels verified": {
"bg": " - {count} standard labels verified",
"de": " - {count} standard labels verified",
"en": " - {count} standard labels verified",
"pl": " - {count} standard labels verified",
"ru": " - {count} standard labels verified",
"zh": " - {count} standard labels verified"
},
"[check-deps] Virtualenv .venv ready (Python {version}).": {
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
},
"{level}: {tool} not found.{hint}": {
"en": "{level}: {tool} not found.{hint}",
"bg": "{level}: {tool} не е намерен.{hint}",
"de": "{level}: {tool} nicht gefunden.{hint}",
"pl": "{level}: {tool} nie znaleziono.{hint}",
"ru": "{level}: {tool} не найден.{hint}",
"zh": "{level}: 未找到 {tool}。{hint}"
},
"WARN: Could not determine Python version in .venv.": {
"en": "WARN: Could not determine Python version in .venv.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
},
"WARN: Could not parse Python version '{version}'.": {
"en": "WARN: Could not parse Python version '{version}'.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
"zh": "警告: 无法解析 Python 版本 '{version}'。"
},
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
},
"[docker-login] Logged in to {registry}.": {
"en": "[docker-login] Logged in to {registry}.",
"bg": "[docker-login] Влязъл в {registry}.",
"de": "[docker-login] Angemeldet bei {registry}.",
"pl": "[docker-login] Zalogowano do {registry}.",
"ru": "[docker-login] Выполнен вход в {registry}.",
"zh": "[docker-login] 已登录到 {registry}。"
},
"[docker-login] Login to {registry} failed (continuing).": {
"en": "[docker-login] Login to {registry} failed (continuing).",
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
},
"[docker-login] Skipping {registry} (token {env} not set).": {
"en": "[docker-login] Skipping {registry} (token {env} not set).",
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
},
"{env} is not set. Set it in your .env file.": {
"en": "{env} is not set. Set it in your .env file.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
"ru": "{env} не задан. Установите его в файле .env.",
"zh": "{env} 未设置。请在 .env 文件中设置。"
},
"{env} is not set. Set it in your .env file or pass it as an environment variable.": {
"en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
@@ -3175,188 +3551,76 @@
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
},
"Login to {registry} failed: {error}": {
"en": "Login to {registry} failed: {error}",
"bg": "Влизането в {registry} не успя: {error}",
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
"pl": "Logowanie do {registry} nie powiodło się: {error}",
"ru": "Ошибка входа в {registry}: {error}",
"zh": "登录 {registry} 失败: {error}"
"{env} is not set. Set it in your .env file.": {
"en": "{env} is not set. Set it in your .env file.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
"ru": "{env} не задан. Установите его в файле .env.",
"zh": "{env} 未设置。请在 .env 文件中设置。"
},
"tofu command failed in {dir}: {error}": {
"en": "tofu command failed in {dir}: {error}",
"bg": "командата tofu не успя в {dir}: {error}",
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
"ru": "команда tofu не удалась в {dir}: {error}",
"zh": "tofu 命令在 {dir} 中失败: {error}"
"{file} already exists. Use --force to overwrite.": {
"bg": "{file} already exists. Use --force to overwrite.",
"de": "{file} already exists. Use --force to overwrite.",
"en": "{file} already exists. Use --force to overwrite.",
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
"ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite."
},
"WARN: .venv has Python {version}, but >={req} is required.": {
"en": "WARN: .venv has Python {version}, but >={req} is required.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
"{level}: {tool} not found.{hint}": {
"en": "{level}: {tool} not found.{hint}",
"bg": "{level}: {tool} не е намерен.{hint}",
"de": "{level}: {tool} nicht gefunden.{hint}",
"pl": "{level}: {tool} nie znaleziono.{hint}",
"ru": "{level}: {tool} не найден.{hint}",
"zh": "{level}: 未找到 {tool}。{hint}"
},
" -> {dir}": {
"en": " -> {dir}",
"bg": " -> {dir}",
"de": " -> {dir}",
"pl": " -> {dir}",
"ru": " -> {dir}",
"zh": " -> {dir}"
"{separator}": {
"bg": "{separator}",
"de": "{separator}",
"en": "{separator}",
"pl": "{separator}",
"ru": "{separator}",
"zh": "{separator}"
},
"[tofu-init] Initializing {dir}...": {
"en": "[tofu-init] Initializing {dir}...",
"bg": "[tofu-init] Инициализиране на {dir}...",
"de": "[tofu-init] Initialisiere {dir}...",
"pl": "[tofu-init] Inicjalizacja {dir}...",
"ru": "[tofu-init] Инициализация {dir}...",
"zh": "[tofu-init] 正在初始化 {dir}..."
"Allow empty tag (PR mode where SHA is concrete).": {
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
"en": "Allow empty tag (PR mode where SHA is concrete).",
"pl": "Zezwalaj na pusty tag (tryb PR, w którym SHA jest konkretne).",
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
},
"[tofu-init] Done.": {
"en": "[tofu-init] Done.",
"bg": "[tofu-init] Готово.",
"de": "[tofu-init] Fertig.",
"pl": "[tofu-init] Gotowe.",
"ru": "[tofu-init] Готово.",
"zh": "[tofu-init] 完成。"
"Git tag or ref that was deployed": {
"bg": "Git таг или референция, която беше разгърната",
"de": "Git-Tag oder Ref, der bereitgestellt wurde",
"en": "Git tag or ref that was deployed",
"pl": "Tag Git lub ref, który został wdrożony",
"ru": "Git-тег или ссылка, которые были развёрнуты",
"zh": "已部署的 Git 标签或引用"
},
"[tofu-{mode}] Validating OpenTofu configurations...": {
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
"Git tag to deploy (e.g. v0.28.1).": {
"bg": "Git таг за разгръщане (напр. v0.28.1).",
"de": "Git-Tag für Bereitstellung (z.B. v0.28.1).",
"en": "Git tag to deploy (e.g. v0.28.1).",
"pl": "Tag Git do wdrożenia (np. v0.28.1).",
"ru": "Git-тег для развёртывания (напр. v0.28.1).",
"zh": "要部署的 Git 标签(例如 v0.28.1)。"
},
"[tofu-{mode}] All configurations valid.": {
"en": "[tofu-{mode}] All configurations valid.",
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
"ru": "[tofu-{mode}] Все конфигурации валидны.",
"zh": "[tofu-{mode}] 所有配置有效。"
"Write deploy-ref to $GITHUB_OUTPUT file.": {
"bg": "Запиши deploy-ref в $GITHUB_OUTPUT файла.",
"de": "Deploy-ref in $GITHUB_OUTPUT-Datei schreiben.",
"en": "Write deploy-ref to $GITHUB_OUTPUT file.",
"pl": "Zapisz deploy-ref do pliku $GITHUB_OUTPUT.",
"ru": "Записать deploy-ref в файл $GITHUB_OUTPUT.",
"zh": "将 deploy-ref 写入 $GITHUB_OUTPUT 文件。"
},
"[check-deps] Verifying tools...": {
"en": "[check-deps] Verifying tools...",
"bg": "[check-deps] Проверка на инструментите...",
"de": "[check-deps] Werkzeuge werden überprüft...",
"pl": "[check-deps] Sprawdzanie narzędzi...",
"ru": "[check-deps] Проверка инструментов...",
"zh": "[check-deps] 正在验证工具..."
},
" {tool}: found at {path}": {
"en": " {tool}: found at {path}",
"bg": " {tool}: намерен на {path}",
"de": " {tool}: gefunden unter {path}",
"pl": " {tool}: znaleziono w {path}",
"ru": " {tool}: найден в {path}",
"zh": " {tool}: 在 {path} 找到"
},
" Run 'make install-checkmake' to install the Makefile linter.": {
"en": " Run 'make install-checkmake' to install the Makefile linter.",
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
},
"Required tools missing.": {
"en": "Required tools missing.",
"bg": "Липсват задължителни инструменти.",
"de": "Erforderliche Werkzeuge fehlen.",
"pl": "Brak wymaganych narzędzi.",
"ru": "Отсутствуют обязательные инструменты.",
"zh": "缺少必需的工具。"
},
"[check-deps] All core tools present.": {
"en": "[check-deps] All core tools present.",
"bg": "[check-deps] Всички основни инструменти са налични.",
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
"ru": "[check-deps] Все основные инструменты доступны.",
"zh": "[check-deps] 所有核心工具均已就绪。"
},
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
},
"Failed to start ssh-agent: {error}": {
"en": "Failed to start ssh-agent: {error}",
"bg": "Неуспешно стартиране на ssh-agent: {error}",
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
"pl": "Nie udało się uruchomić ssh-agent: {error}",
"ru": "Не удалось запустить ssh-agent: {error}",
"zh": "启动 ssh-agent 失败: {error}"
},
"SSH key set up successfully": {
"en": "SSH key set up successfully",
"bg": "SSH ключът е настроен успешно",
"de": "SSH-Schlüssel erfolgreich eingerichtet",
"pl": "Klucz SSH skonfigurowany pomyślnie",
"ru": "SSH-ключ успешно настроен",
"zh": "SSH 密钥设置成功"
},
"SSH key setup skipped (no key provided)": {
"en": "SSH key setup skipped (no key provided)",
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
"zh": "SSH 密钥设置已跳过(未提供密钥)"
},
"Found {count} unsafe identity check(s) in integration tests.": {
"en": "Found {count} unsafe identity check(s) in integration tests.",
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
},
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
},
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
},
"Directory to scan (default: tests/integration). Can be repeated.": {
"en": "Directory to scan (default: tests/integration). Can be repeated.",
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
},
"Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.": {
"bg": "Неуспешно извличане на съществуващи wiki страници след повторни опити: {error}. Прекратяване, за да се избегне създаване на дублирани страници.",
"de": "Abrufen bestehender Wiki-Seiten nach Wiederholungen fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.",
"en": "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.",
"pl": "Nie udało się wylistować istniejących stron wiki po ponownych próbach: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.",
"ru": "Не удалось получить список существующих wiki-страниц после повторных попыток: {error}. Прерывание, чтобы избежать создания дубликатов страниц.",
"zh": "重试后列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。"
},
" Page '{title}' already exists (stale list). Re-listing and updating...": {
"bg": " Страницата '{title}' вече съществува (остарял списък). Пресписване и обновяване...",
"de": " Seite '{title}' existiert bereits (veraltete Liste). Neu auflisten und aktualisieren...",
"en": " Page '{title}' already exists (stale list). Re-listing and updating...",
"pl": " Strona '{title}' już istnieje (nieaktualna lista). Ponowne listowanie i aktualizacja...",
"ru": " Страница '{title}' уже существует (устаревший список). Повторное получение списка и обновление...",
"zh": " 页面 '{title}' 已存在(列表过期)。重新列出并更新..."
"Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.": {
"bg": "Заглавието на задачата във Vikunja '{title}' започва с '{prefix}:'. Заглавието на задачата НЕ трябва да съдържа префикса '{prefix}' — той се добавя автоматично към заглавието на PR. Актуализирайте заглавието на задачата във Vikunja, за да премахнете префикса.",
"de": "Der Vikunja-Aufgabentitel '{title}' beginnt mit '{prefix}:'. Der Aufgabentitel darf NICHT den Präfix '{prefix}' enthalten — er wird automatisch zum PR-Titel hinzugefügt. Aktualisieren Sie den Vikunja-Aufgabentitel, um den Präfix zu entfernen.",
"en": "Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.",
"pl": "Tytuł zadania Vikunja '{title}' zaczyna się od '{prefix}:'. Tytuł zadania nie powinien zawierać prefiksu '{prefix}' — jest on automatycznie dodawany do tytułu PR. Zaktualizuj tytuł zadania Vikunja, aby usunąć prefiks.",
"ru": "Заголовок задачи Vikunja '{title}' начинается с '{prefix}:'. Заголовок задачи НЕ должен включать префикс '{prefix}' — он автоматически добавляется к заголовку PR. Обновите заголовок задачи Vikunja, чтобы удалить префикс.",
"zh": "Vikunja 任务标题 '{title}' 以 '{prefix}:' 开头。任务标题不应包含 '{prefix}' 前缀 — 它会自动添加到 PR 标题中。请更新 Vikunja 任务标题以删除前缀。"
}
}
+27
View File
@@ -0,0 +1,27 @@
"""Typed confirmation validation for destructive operations.
Ensures the user typed an exact confirmation phrase before proceeding
with dangerous operations (e.g. production deploys, database migrations).
Usage::
from devx.utils.confirm import validate_confirmation
if not validate_confirmation(user_input, expected="deploy-production"):
raise SystemExit("Confirmation does not match")
"""
from __future__ import annotations
def validate_confirmation(confirm: str, expected: str) -> bool:
"""Check if confirmation text matches the expected phrase.
Args:
confirm: The confirmation text entered by the user.
expected: The exact phrase that must be matched.
Returns:
True if confirmation matches exactly, False otherwise.
"""
return confirm == expected
+73
View File
@@ -0,0 +1,73 @@
"""Cryptographic secret generation helpers.
Provides safe secret/password generators that avoid shell-option
interpretation issues (e.g. leading ``-`` being parsed as a flag by
``su -c`` in Docker entrypoints).
Usage::
from devx.utils.crypto import generate_secret, generate_password
api_key = generate_secret()
db_password = generate_password(length=32)
"""
from __future__ import annotations
import secrets
_SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?"
_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_LOWER = "abcdefghijklmnopqrstuvwxyz"
_DIGITS = "0123456789"
def generate_secret() -> str:
"""Generate a URL-safe secret that never starts with ``-``.
A leading ``-`` causes passwords to be interpreted as command-line
options when passed through shell expansion chains (e.g. Nextcloud's
Docker entrypoint uses ``su -c`` which strips quoting).
Returns:
A 43-character URL-safe base64 secret.
"""
value = secrets.token_urlsafe(32)
while value.startswith("-"):
value = secrets.token_urlsafe(32)
return value
def generate_password(length: int = 32) -> str:
"""Generate a password guaranteed to contain upper, lower, digit, and symbol.
The first character is always alphanumeric to avoid being interpreted
as a command-line option when passed through shell expansion chains.
Args:
length: Desired password length (minimum 4).
Returns:
A password string with guaranteed character class coverage.
"""
pools = [_UPPER, _LOWER, _DIGITS, _SYMBOLS]
chars = [secrets.choice(p) for p in pools]
all_chars = "".join(pools)
chars += [secrets.choice(all_chars) for _ in range(length - len(pools))]
secrets.SystemRandom().shuffle(chars)
while chars[0] in _SYMBOLS:
secrets.SystemRandom().shuffle(chars)
return "".join(chars)
def generate_hex_secret(length: int = 32) -> str:
"""Generate a hexadecimal secret of the given length.
Args:
length: Desired number of hex characters (doubled internally
since ``token_hex`` produces pairs).
Returns:
A hexadecimal string.
"""
return secrets.token_hex(length // 2)
+128
View File
@@ -0,0 +1,128 @@
"""File-locked JSON registry for local state management.
Provides a simple JSON-backed key-value store with ``fcntl`` file
locking for safe concurrent access. Useful for CLI tools that need
to track remote resources (runners, VMs, deployments) on the local
machine.
Usage::
from devx.utils.json_registry import JsonRegistry
registry = JsonRegistry(Path("~/.local/share/myapp/state.json"))
registry.add("item1", host="10.0.0.1", user="deploy")
info = registry.get("item1")
registry.remove("item1")
"""
from __future__ import annotations
import copy
import fcntl
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
class JsonRegistry:
"""Manages a local JSON file mapping names to arbitrary metadata.
Uses ``fcntl`` for file locking (shared lock for reads, exclusive
lock for writes) to prevent race conditions in concurrent scenarios.
"""
def __init__(self, path: Path | None = None) -> None:
"""Initialise the registry.
Args:
path: Path to the JSON file. Defaults to
``~/.local/share/devx/registry.json``.
"""
self._path = path or Path.home() / ".local" / "share" / "devx" / "registry.json"
self._data: dict[str, dict[str, Any]] = self._load()
def _load(self) -> dict[str, dict[str, Any]]:
if not self._path.exists():
return {}
try:
with open(self._path) as f:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
data: Any = json.load(f)
if isinstance(data, dict):
return cast(dict[str, dict[str, Any]], data)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except (json.JSONDecodeError, OSError):
pass
return {}
def _save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(self._path, "w") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(self._data, f, indent=2)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def add(self, name: str, **fields: Any) -> None:
"""Register or overwrite an entry in the registry.
Args:
name: Unique key for the entry.
**fields: Arbitrary metadata fields to store.
"""
self._data[name] = {
**fields,
"created_at": datetime.now(UTC).isoformat(),
}
self._save()
def get(self, name: str) -> dict[str, Any] | None:
"""Retrieve entry metadata by name.
Args:
name: Key to look up.
Returns:
A copy of the entry's metadata, or None if not found.
"""
info = self._data.get(name)
if info:
return copy.deepcopy(info)
return None
def remove(self, name: str) -> None:
"""Remove an entry from the registry.
Args:
name: Key to remove. No-op if not found.
"""
if name in self._data:
del self._data[name]
self._save()
def list(self) -> dict[str, dict[str, Any]]:
"""Return a copy of all registered entries.
Returns:
Dict mapping names to metadata copies.
"""
return {name: copy.deepcopy(info) for name, info in self._data.items()}
def update(self, name: str, **fields: Any) -> None:
"""Update fields for an existing entry.
Args:
name: Key to update.
**fields: Fields to update (None values are skipped).
Raises:
KeyError: If the entry doesn't exist.
"""
if name not in self._data:
raise KeyError(name)
self._data[name].update({k: v for k, v in fields.items() if v is not None})
self._save()
+48
View File
@@ -0,0 +1,48 @@
"""XDG-compliant logging configuration for CLI tools.
Provides a standardised logging setup that writes to
``~/.local/state/<app>/logs/<app>.log`` following the XDG state
directory specification. Console output is handled separately by
the application (e.g. via ``click.echo``).
Usage::
from devx.utils.logging import get_logger
logger = get_logger("myapp")
logger.info("Application started")
"""
from __future__ import annotations
import logging
from pathlib import Path
def get_logger(name: str = "devx") -> logging.Logger:
"""Return a configured logger that writes to an XDG state directory.
All messages (including DEBUG) are written to
``~/.local/state/<name>/logs/<name>.log``. Console output is
expected to be handled by the application via ``click.echo``.
Args:
name: Logger name and subdirectory name for log files.
Returns:
A configured :class:`logging.Logger` instance.
"""
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(logging.DEBUG)
log_dir = Path.home() / ".local" / "state" / name / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_dir / f"{name}.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logger.addHandler(file_handler)
return logger
+102
View File
@@ -0,0 +1,102 @@
"""Network connectivity helpers.
Provides retry-aware HTTP connectivity checks and SSH availability
checks for deployment workflows. Uses ``tenacity`` for exponential
backoff retry logic.
Usage::
from devx.utils.network import check_http_connectivity, wait_for_ssh
check_http_connectivity("https://auth.example.com")
wait_for_ssh("178.105.254.83")
"""
from __future__ import annotations
import logging
import socket
import time
from collections.abc import Callable
import requests
from tenacity import (
Retrying,
before_sleep_log,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
def check_http_connectivity(
base_url: str,
max_attempts: int = 30,
*,
verify: bool = True,
sleep: Callable[[float], None] | None = None,
) -> None:
"""Verify HTTP reachability of *base_url* with retry.
Uses tenacity for retry with exponential backoff (2 s min, 10 s max).
Args:
base_url: URL to check via GET request.
max_attempts: Maximum retry attempts.
verify: Whether to verify TLS certificates.
sleep: Custom sleep function for testing (defaults to ``time.sleep``).
Raises:
requests.exceptions.ConnectionError: If the URL is not reachable
after *max_attempts*.
"""
retrying = Retrying(
stop=stop_after_attempt(max_attempts),
wait=wait_exponential(multiplier=2, min=2, max=10),
retry=retry_if_exception_type(requests.exceptions.ConnectionError),
before_sleep=before_sleep_log(logging.getLogger("devx.utils.network"), logging.WARNING),
sleep=sleep if sleep is not None else time.sleep,
reraise=True,
)
def _check() -> None:
requests.get(base_url, timeout=10, verify=verify) # nosec B501
retrying(_check)
def wait_for_ssh(
host: str,
port: int = 22,
max_attempts: int = 30,
interval: int = 10,
*,
sleep: Callable[[float], None] | None = None,
) -> None:
"""Wait for SSH to be available on a host using a pure-Python socket check.
Uses socket instead of ``nc(1)`` so it works on CI runners without
netcat. Uses exponential backoff: starts at 2 s, doubles each
attempt up to 10 s max.
Args:
host: VM IP address or hostname.
port: SSH port (default 22).
max_attempts: Maximum number of connection attempts.
interval: Base interval for backoff calculation (seconds).
sleep: Custom sleep function for testing (defaults to ``time.sleep``).
Raises:
RuntimeError: If SSH is not available after *max_attempts*.
"""
_sleep = sleep if sleep is not None else time.sleep
for i in range(max_attempts):
try:
with socket.create_connection((host, port), timeout=5):
return
except OSError:
pass
if i < max_attempts - 1:
wait = min(2 * (2**i), 10)
_sleep(wait)
raise RuntimeError(f"SSH not available on {host}:{port} after {max_attempts} attempts")
+132
View File
@@ -0,0 +1,132 @@
"""SSH helpers for running commands on remote hosts.
Provides a simple wrapper around the ``ssh`` CLI for executing commands
on remote machines (e.g. customer VMs, CI runners) without requiring
Ansible. Includes a pure-Python ``wait_for_ssh`` that uses socket
instead of ``nc(1)`` so it works on minimal CI containers.
Usage::
from devx.utils.ssh import ssh_exec, wait_for_ssh
wait_for_ssh("178.105.254.83")
result = ssh_exec("178.105.254.83", "uname -a")
print(result.stdout)
"""
from __future__ import annotations
import socket
import subprocess # nosec B404
import sys
import time
SSH_CONNECT_TIMEOUT = "10"
SSH_HOST_KEY_CHECKING = "no"
def ssh_exec(
host: str,
command: str,
*,
user: str = "deploy",
timeout: int = 30,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Run *command* on *host* via SSH and return the result.
Args:
host: VM IP address or hostname.
command: Shell command to execute on the remote host.
user: SSH user (default ``deploy``).
timeout: Subprocess timeout in seconds.
check: If True, raise ``CalledProcessError`` on non-zero exit.
Returns:
The completed process result with stdout/stderr captured.
"""
result = subprocess.run( # nosec B603, B607, B607
[
"ssh",
"-o",
f"StrictHostKeyChecking={SSH_HOST_KEY_CHECKING}",
"-o",
f"ConnectTimeout={SSH_CONNECT_TIMEOUT}",
f"{user}@{host}",
command,
],
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
if check and result.returncode != 0:
print(f"SSH command failed on {host}: {command}", file=sys.stderr)
print(f" stdout: {result.stdout.strip()}", file=sys.stderr)
print(f" stderr: {result.stderr.strip()}", file=sys.stderr)
result.check_returncode()
return result
def docker_exec_on_vm(
host: str,
container: str,
command: str,
*,
user: str = "deploy",
db_user: str | None = None,
db_name: str | None = None,
timeout: int = 30,
) -> str:
"""Run a command inside a Docker container on a remote VM via SSH.
For PostgreSQL commands, set *db_user* and *db_name* to run
``psql -U <db_user> -d <db_name> -c <command>`` inside the container.
Args:
host: VM IP address or hostname.
container: Docker container name on the remote host.
command: Command to execute inside the container (or SQL if db_user/db_name set).
user: SSH user (default ``deploy``).
db_user: PostgreSQL user name (enables psql mode).
db_name: PostgreSQL database name (enables psql mode).
timeout: Subprocess timeout in seconds.
Returns:
Stripped stdout from the command.
"""
if db_user and db_name:
escaped_sql = command.replace("'", "'\"'\"'")
remote_cmd = f'docker exec {container} psql -U {db_user} -d {db_name} -t -A -c "{escaped_sql}"'
else:
remote_cmd = f"docker exec {container} {command}"
result = ssh_exec(host, remote_cmd, user=user, timeout=timeout)
return result.stdout.strip()
def wait_for_ssh(host: str, port: int = 22, max_attempts: int = 30, interval: int = 10) -> None:
"""Wait for SSH to be available on a host using a pure-Python socket check.
Uses socket instead of ``nc(1)`` so it works on CI runners without
netcat. Uses exponential backoff: starts at 2 s, doubles each
attempt up to 10 s max.
Args:
host: VM IP address or hostname.
port: SSH port (default 22).
max_attempts: Maximum number of connection attempts.
interval: Base interval for backoff calculation (seconds).
Raises:
RuntimeError: If SSH is not available after *max_attempts*.
"""
for i in range(max_attempts):
try:
with socket.create_connection((host, port), timeout=5):
return
except OSError:
pass
if i < max_attempts - 1:
wait = min(2 * (2**i), 10)
time.sleep(wait)
raise RuntimeError(f"SSH not available on {host}:{port} after {max_attempts} attempts")
+102
View File
@@ -0,0 +1,102 @@
"""Operation step tracking with translated reports.
Provides a context manager that tracks multi-step operations and prints
a status report on exit. Steps are marked as pending, in_progress,
completed, or failed. On exception, the last in-progress step is
marked as failed.
Usage::
from devx.utils.step_tracker import track_steps
with track_steps() as tracker:
tracker.begin("Install dependencies")
install_deps()
tracker.done()
tracker.begin("Run tests")
run_tests()
tracker.done()
"""
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
import click
_STATUS_ICONS = {
"completed": "",
"failed": "",
"pending": "",
"in_progress": "",
}
_STATUS_COLORS = {
"completed": "green",
"failed": "red",
"in_progress": "yellow",
"pending": "white",
}
class Step:
"""A single tracked step in an operation."""
def __init__(self, name: str) -> None:
self.name = name
self.status = "pending"
class StepTracker:
"""Tracks steps of an operation and prints a report on exit."""
def __init__(self) -> None:
self.steps: list[Step] = []
def begin(self, name: str) -> None:
"""Start a new step.
Args:
name: Human-readable step name.
"""
step = Step(name)
self.steps.append(step)
step.status = "in_progress"
def done(self) -> None:
"""Mark the most recent in-progress step as completed."""
if self.steps and self.steps[-1].status == "in_progress":
self.steps[-1].status = "completed"
@contextmanager
def track_steps() -> Generator[StepTracker, None, None]:
"""Context manager that tracks steps and prints a report on exit.
On exception the last in-progress step is marked as failed.
The report is printed in the ``finally`` block so it always appears.
Yields:
A :class:`StepTracker` instance to track steps with.
"""
tracker = StepTracker()
try:
yield tracker
except Exception:
for step in reversed(tracker.steps):
if step.status == "in_progress":
step.status = "failed"
raise
finally:
_print_report(tracker.steps)
def _print_report(steps: list[Step]) -> None:
"""Print an operation report to stdout."""
click.secho("=== Operation Report ===", fg="bright_cyan")
for step in steps:
icon = _STATUS_ICONS.get(step.status, "?")
color = _STATUS_COLORS.get(step.status)
click.secho(f" {icon} {step.name} ({step.status})", fg=color)
+135
View File
@@ -0,0 +1,135 @@
"""Ansible Vault helpers for encrypting and decrypting YAML files.
Wraps ``ansible-vault`` to provide a convenient API for loading and
saving vault-encrypted YAML files. Falls back to plain YAML when no
vault-password file is available, making it safe to use in both
local (with vault) and CI (without vault) environments.
Usage::
from devx.utils.vault import load_vault_yaml, save_vault_yaml
data = load_vault_yaml(Path("secrets.yml"), vault_pass=Path("vault-password"))
data["new_key"] = "value"
save_vault_yaml(Path("secrets.yml"), data, vault_pass=Path("vault-password"))
"""
from __future__ import annotations
import subprocess # nosec B404
from pathlib import Path
import yaml
def encrypt_file(path: Path, vault_pass: Path) -> None:
"""Encrypt a file in-place using ansible-vault.
Args:
path: File to encrypt.
vault_pass: Path to the vault-password file.
"""
subprocess.run( # nosec B603, B607
[
"ansible-vault",
"encrypt",
str(path),
"--vault-password-file",
str(vault_pass),
"--encrypt-vault-id",
"default",
],
check=True,
)
def decrypt_file(path: Path, vault_pass: Path) -> None:
"""Decrypt a file in-place using ansible-vault.
Args:
path: File to decrypt.
vault_pass: Path to the vault-password file.
"""
subprocess.run( # nosec B603, B607
[
"ansible-vault",
"decrypt",
str(path),
"--vault-password-file",
str(vault_pass),
],
check=True,
)
def load_vault_yaml(path: Path, vault_pass: Path | None = None) -> dict:
"""Load a YAML file, decrypting with ansible-vault if vault-password exists.
If *vault_pass* is None or doesn't exist, the file is read as plain
YAML. If decryption fails (file not vault-encrypted), it falls back
to plain YAML.
Args:
path: YAML file path.
vault_pass: Path to the vault-password file (optional).
Returns:
Parsed YAML content as a dict (empty dict if file is empty).
"""
if vault_pass is None or not vault_pass.exists():
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
result = subprocess.run( # nosec B603, B607
["ansible-vault", "view", str(path), "--vault-password-file", str(vault_pass)],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return yaml.safe_load(result.stdout) or {}
if "is not vault encrypted" in result.stderr:
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
result.check_returncode() # pragma: no cover
return {} # pragma: no cover
def save_vault_yaml(path: Path, data: dict, vault_pass: Path | None = None) -> None:
"""Write YAML data, encrypting with ansible-vault if vault-password exists.
Args:
path: Destination YAML file path.
data: Data to serialize.
vault_pass: Path to the vault-password file (optional).
"""
plain = yaml.dump(data, default_flow_style=False, sort_keys=False)
with open(path, "w", encoding="utf-8") as f:
f.write(plain)
if vault_pass is not None and vault_pass.exists():
subprocess.run( # nosec B603, B607
[
"ansible-vault",
"encrypt",
str(path),
"--vault-password-file",
str(vault_pass),
"--encrypt-vault-id",
"default",
],
capture_output=True,
check=True,
)
def is_encrypted(path: Path) -> bool:
"""Check if a file is ansible-vault encrypted.
Args:
path: File to check.
Returns:
True if the file starts with the ``$ANSIBLE_VAULT`` marker.
"""
with open(path, encoding="utf-8") as f:
first_line = f.readline()
return "$ANSIBLE_VAULT" in first_line
+62
View File
@@ -909,3 +909,65 @@ class TestGiteaClientActions:
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
timeout=DEFAULT_TIMEOUT,
)
def test_get_repo_variable_returns_value(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"value": "v0.28.1"}))
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
assert result == "v0.28.1"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
timeout=DEFAULT_TIMEOUT,
)
def test_get_repo_variable_returns_none_on_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
client._session.request = MagicMock(return_value=not_found)
result = client.get_repo_variable("MISSING_VAR")
assert result is None
@patch("time.sleep")
def test_get_repo_variable_reraises_non_404(self, mock_sleep: MagicMock) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
server_error = MagicMock()
server_error.raise_for_status.side_effect = _mock_http_error(500, "server error")
client._session.request = MagicMock(return_value=server_error)
with pytest.raises(APIError) as exc_info:
client.get_repo_variable("SOME_VAR")
assert exc_info.value.status == 500
def test_set_repo_variable_updates_existing(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
client.set_repo_variable("PRODUCTION_DEPLOY_TAG", "v0.28.2")
client._session.request.assert_called_once_with(
"PUT",
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
timeout=DEFAULT_TIMEOUT,
json={"value": "v0.28.2"},
)
def test_set_repo_variable_creates_on_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
created = _mock_response({})
client._session.request = MagicMock(side_effect=[not_found, created])
client.set_repo_variable("NEW_VAR", "v0.29.0")
assert client._session.request.call_count == 2
second_call = client._session.request.call_args_list[1]
assert second_call.args[0] == "POST"
assert second_call.args[1] == "https://git.example.com/repos/owner/repo/actions/variables/NEW_VAR"
assert second_call.kwargs["json"] == {"value": "v0.29.0"}
def test_set_repo_variable_reraises_non_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
forbidden = MagicMock()
forbidden.raise_for_status.side_effect = _mock_http_error(403, "forbidden")
client._session.request = MagicMock(return_value=forbidden)
with pytest.raises(APIError) as exc_info:
client.set_repo_variable("SOME_VAR", "val")
assert exc_info.value.status == 403
+15
View File
@@ -292,3 +292,18 @@ class TestCli:
)
assert result.exit_code != 0
assert "does not match Vikunja" in result.output
def test_fails_with_double_prefix_in_vikunja_title(self) -> None:
"""Vikunja title with task ID prefix causes double-prefix in PR title."""
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="DEVX-1: Fix foo"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "should NOT include" in result.output
+12
View File
@@ -4,6 +4,7 @@ import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
@@ -249,3 +250,14 @@ class TestMain:
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
@patch("devx.ci.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
"""When no token is available, runner discovery falls back to env/default."""
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "3"
args, _ = mock_count.call_args
assert args[1] is None # token passed as None when missing
+200
View File
@@ -55,6 +55,38 @@ class TestExtractCliCommands:
assert "real_cmd" in commands
assert "pass" not in commands
def test_command_with_explicit_name_param(self, tmp_path: Path) -> None:
"""When a command uses name="explicit-name", that name is extracted."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
'@click.group()\ndef cli():\n pass\n@cli.command(name="my-command")\ndef my_command():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "my-command" in commands
assert "my_command" not in commands
def test_command_with_help_kwarg_uses_def_name(self, tmp_path: Path) -> None:
"""When a command uses help= kwarg but no name=, falls back to def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n"
'@cli.command(help="Do something useful")\ndef do_something():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "do_something" in commands
assert "Do something useful" not in commands
def test_command_with_help_translation_uses_def_name(self, tmp_path: Path) -> None:
"""When a command uses help=_() translation, falls back to def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n"
'@cli.command(help=_("Install and configure things"))\ndef install():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "install" in commands
assert "Install and configure things" not in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
@@ -195,3 +227,171 @@ class TestMain:
result = runner.invoke(main, ["--docs-dir", str(docs)])
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
assert result.exit_code == 0
def test_ci_scripts_dir_empty_skips_ci_checks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir is empty string, CI script checks are skipped."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", ""])
assert result.exit_code == 0
assert "100%" in result.output
# Should not mention any CI scripts
assert "MISSING" not in result.output or "CI script" not in result.output
def test_ci_scripts_dir_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir points to a directory, scripts are detected from there."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
ci_dir = tmp_path / "ci"
ci_dir.mkdir()
(ci_dir / "my_script.py").write_text("# my script")
(ci_dir / "__init__.py").write_text("")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("")
(docs / "tech" / "ci-cd-workflow.md").write_text("my_script.py")
runner = CliRunner()
result = runner.invoke(
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", str(ci_dir)]
)
assert result.exit_code == 0
assert "my_script.py" in result.output
assert "OK: my_script.py" in result.output
def test_ci_scripts_dir_nonexistent_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir points to a non-existent path, CI checks are skipped."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", "/nonexistent"]
)
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_ci_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] ci_scripts_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with ci_scripts_dir = ""
(tmp_path / "pyproject.toml").write_text('[tool.devx.doc_coverage]\nci_scripts_dir = ""\n')
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_docs_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] docs_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
custom_docs = tmp_path / "custom-docs"
(custom_docs / "user").mkdir(parents=True)
(custom_docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(custom_docs / "user" / "cli-commands.md").write_text("## release\n")
(custom_docs / "tech" / "architecture.md").write_text("config.py")
(custom_docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with custom docs_dir
(tmp_path / "pyproject.toml").write_text(
f'[tool.devx.doc_coverage]\ndocs_dir = "{custom_docs}"\nci_scripts_dir = ""\n'
)
runner = CliRunner()
result = runner.invoke(main, ["--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_source_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] source_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
custom_src = tmp_path / "custom-src" / "myapp"
custom_src.mkdir(parents=True)
(custom_src / "__init__.py").write_text("")
(custom_src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(custom_src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with custom source_dir
(tmp_path / "pyproject.toml").write_text(
f'[tool.devx.doc_coverage]\nsource_dir = "{custom_src}"\nci_scripts_dir = ""\n'
)
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_doc_coverage_not_dict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] is not a dict, falls back to defaults."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with doc_coverage as a non-dict value
(tmp_path / "pyproject.toml").write_text('[tool.devx]\ndoc_coverage = "not-a-dict"\n')
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
+22
View File
@@ -186,6 +186,28 @@ class TestCli:
timeout=60,
)
@patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token"))
def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None:
"""When no token is available, cross-runner polling is skipped."""
from click.testing import CliRunner
with (
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.poll_for_other_failures") as mock_poll,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
mock_poll.assert_not_called()
def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise."""
from click.testing import CliRunner
@@ -4,6 +4,7 @@ import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
@@ -219,3 +220,14 @@ class TestMain:
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
@patch("devx.molecule.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
"""When no token is available, runner discovery falls back to env/default."""
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "3"
args, _ = mock_count.call_args
assert args[1] is None # token passed as None when missing
+7 -2
View File
@@ -12,9 +12,14 @@ from devx.tools.pr_label import cli
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
result = runner.invoke(
cli,
["--pr", "42", "--label", "ready-to-merge"],
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
)
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
+7 -2
View File
@@ -153,9 +153,14 @@ class TestPrintLogs:
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
result = runner.invoke(
cli,
["--pr", "42"],
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
)
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
+77 -3
View File
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.ci.pr_review import (
@@ -756,9 +757,10 @@ class TestMain:
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0
@patch.dict("os.environ", {"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": ""})
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@@ -901,7 +903,12 @@ class TestManualReview:
mock_client_class.return_value.create_review.assert_not_called()
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_fallback(self, mock_client_class: MagicMock) -> None:
def test_manual_review_self_approval_fallback_to_comment(
self, mock_client_class: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Self-approval with no CI token available → fall back to COMMENT."""
monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False)
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
@@ -921,10 +928,77 @@ class TestManualReview:
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer"},
)
assert result.exit_code == 0
assert "Review #202" in result.output
# Without CI_GITEA_API_TOKEN, the fallback is COMMENT
assert "Self-approval not allowed. Posting COMMENT instead." in result.output
assert client.create_review.call_count == 2
assert client.create_review.call_args_list[1].kwargs.get("event") == "COMMENT"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_falls_back_to_ci_token(self, mock_client_class: MagicMock) -> None:
"""Self-approval with CI token available → retry APPROVE with CI token (different user)."""
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 303},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer", "CI_GITEA_API_TOKEN": "fake-ci"},
)
assert result.exit_code == 0
assert "Review #303" in result.output
assert "Retrying with CI token" in result.output
# Second call should still be APPROVE (CI token retry)
assert client.create_review.call_count == 2
assert client.create_review.call_args_list[1].kwargs.get("event") == "APPROVE"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_ci_token_also_fails_falls_back_to_comment(self, mock_client_class: MagicMock) -> None:
"""Self-approval + CI token retry also fails → fall back to COMMENT."""
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
APIError(422, "approve your own pull is not allowed"),
{"id": 404},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer", "CI_GITEA_API_TOKEN": "fake-ci"},
)
assert result.exit_code == 0
assert "Review #404" in result.output
assert "CI token also cannot approve" in result.output
# Third call should be COMMENT (final fallback)
assert client.create_review.call_count == 3
assert client.create_review.call_args_list[2].kwargs.get("event") == "COMMENT"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None:
+7 -2
View File
@@ -122,9 +122,14 @@ class TestWaitForCompletion:
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
result = runner.invoke(
cli,
["--pr", "42"],
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
)
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
+62
View File
@@ -0,0 +1,62 @@
"""Unit tests for devx.ci.record_deployed_tag."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.record_deployed_tag import main
class TestRecordDeployedTag:
@patch("devx.ci.record_deployed_tag.GiteaClient")
@patch("devx.ci.record_deployed_tag.get_ci_token")
def test_records_production_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
mock_token.return_value = "fake-token"
client_instance = MagicMock()
mock_client.return_value = client_instance
runner = CliRunner()
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
assert result.exit_code == 0
assert "PRODUCTION_DEPLOY_TAG" in result.output
assert "v1.0.0" in result.output
client_instance.set_repo_variable.assert_called_once_with("PRODUCTION_DEPLOY_TAG", "v1.0.0")
@patch("devx.ci.record_deployed_tag.GiteaClient")
@patch("devx.ci.record_deployed_tag.get_ci_token")
def test_records_staging_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
mock_token.return_value = "fake-token"
client_instance = MagicMock()
mock_client.return_value = client_instance
runner = CliRunner()
result = runner.invoke(main, ["--env", "staging", "--tag", "master-abc123"])
assert result.exit_code == 0
assert "STAGING_DEPLOY_TAG" in result.output
client_instance.set_repo_variable.assert_called_once_with("STAGING_DEPLOY_TAG", "master-abc123")
@patch("devx.ci.record_deployed_tag.get_ci_token")
def test_token_error_exits_nonzero(self, mock_token: MagicMock) -> None:
import click
mock_token.side_effect = click.ClickException("No token available")
runner = CliRunner()
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
assert result.exit_code == 1
assert "No token available" in result.output
def test_invalid_env_choice(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--env", "invalid", "--tag", "v1.0.0"])
assert result.exit_code != 0
def test_missing_tag_option(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--env", "production"])
assert result.exit_code != 0
+8
View File
@@ -34,19 +34,25 @@ class TestRun:
class TestInstallPythonDeps:
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_dev(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "dev")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_ci(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False)
@@ -71,7 +77,9 @@ class TestInstallPythonDeps:
)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=1)
with pytest.raises(subprocess.CalledProcessError):
_install_python_deps(".venv/bin", "ci")
+37 -2
View File
@@ -19,6 +19,7 @@ from devx.ci.sync_wiki import (
main,
sync_files,
transform_links,
wiki_filename,
)
@@ -96,6 +97,29 @@ class TestGetWikiCloneUrl:
assert "owner/repo.wiki.git" in url
class TestWikiFilename:
def test_no_dashes(self) -> None:
assert wiki_filename("Home") == "Home.md"
def test_single_word(self) -> None:
assert wiki_filename("Architecture") == "Architecture.md"
def test_with_dashes_adds_marker(self) -> None:
assert wiki_filename("Getting-Started") == "Getting-Started.-.md"
def test_spaces_become_dashes_with_marker(self) -> None:
# "Getting Started" → "Getting-Started" (has dash) → marker added
assert wiki_filename("Getting Started") == "Getting-Started.-.md"
def test_spaces_no_dashes_no_marker(self) -> None:
# "Foo Bar" → "Foo-Bar" (has dash) → marker added
assert wiki_filename("Foo Bar") == "Foo-Bar.-.md"
def test_single_word_with_spaces_no_dash(self) -> None:
# No dash at all after conversion → no marker
assert wiki_filename("HelloWorld") == "HelloWorld.md"
class TestCloneWiki:
@patch("devx.ci.sync_wiki.subprocess.run")
def test_clone_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
@@ -208,6 +232,8 @@ class TestCommitAndPush:
mock_run.side_effect = [
MagicMock(returncode=0), # git add
MagicMock(returncode=1), # git diff --cached --quiet (has changes)
MagicMock(returncode=0), # git config user.email
MagicMock(returncode=0), # git config user.name
MagicMock(returncode=0), # git commit
MagicMock(returncode=0, stdout="", stderr=""), # git push
]
@@ -219,6 +245,8 @@ class TestCommitAndPush:
mock_run.side_effect = [
MagicMock(returncode=0), # git add
MagicMock(returncode=1), # git diff --cached --quiet (has changes)
MagicMock(returncode=0), # git config user.email
MagicMock(returncode=0), # git config user.name
MagicMock(returncode=0), # git commit
MagicMock(returncode=1, stdout="", stderr="push failed"), # git push
]
@@ -228,9 +256,10 @@ class TestCommitAndPush:
class TestMain:
def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner()
result = runner.invoke(main, [])
result = runner.invoke(main, [], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@@ -319,6 +348,7 @@ class TestMain:
assert result.exit_code == 0
mock_init.assert_called_once()
@patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.clone_wiki")
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
@@ -327,6 +357,7 @@ class TestMain:
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -376,6 +407,7 @@ class TestMain:
assert result.exit_code == 0
assert "No push needed" in result.output
@patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.clone_wiki", side_effect=[True, False])
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
@@ -384,6 +416,7 @@ class TestMain:
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -400,6 +433,7 @@ class TestMain:
assert result.exit_code != 0
assert "could not clone" in result.output
@patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.clone_wiki")
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
@@ -408,6 +442,7 @@ class TestMain:
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
+28
View File
@@ -0,0 +1,28 @@
"""Unit tests for devx.utils.confirm."""
from __future__ import annotations
from devx.utils.confirm import validate_confirmation
class TestValidateConfirmation:
def test_exact_match(self) -> None:
assert validate_confirmation("deploy-production", "deploy-production") is True
def test_mismatch(self) -> None:
assert validate_confirmation("deploy-staging", "deploy-production") is False
def test_empty_string(self) -> None:
assert validate_confirmation("", "deploy-production") is False
def test_case_sensitive(self) -> None:
assert validate_confirmation("Deploy-Production", "deploy-production") is False
def test_partial_match(self) -> None:
assert validate_confirmation("deploy", "deploy-production") is False
def test_extra_whitespace(self) -> None:
assert validate_confirmation("deploy-production ", "deploy-production") is False
def test_custom_expected(self) -> None:
assert validate_confirmation("yes-delete-all", "yes-delete-all") is True
+69
View File
@@ -0,0 +1,69 @@
"""Unit tests for devx.utils.crypto."""
from __future__ import annotations
import re
from devx.utils.crypto import (
_DIGITS,
_LOWER,
_SYMBOLS,
_UPPER,
generate_hex_secret,
generate_password,
generate_secret,
)
class TestGenerateSecret:
def test_returns_url_safe_string(self) -> None:
secret = generate_secret()
assert isinstance(secret, str)
assert len(secret) > 0
# URL-safe base64 characters only
assert re.match(r"^[A-Za-z0-9_-]+$", secret)
def test_never_starts_with_dash(self) -> None:
for _ in range(1000):
secret = generate_secret()
assert not secret.startswith("-")
class TestGeneratePassword:
def test_default_length(self) -> None:
pw = generate_password()
assert len(pw) == 32
def test_custom_length(self) -> None:
pw = generate_password(length=64)
assert len(pw) == 64
def test_contains_all_char_classes(self) -> None:
pw = generate_password(length=32)
assert any(c in _UPPER for c in pw), "Missing uppercase"
assert any(c in _LOWER for c in pw), "Missing lowercase"
assert any(c in _DIGITS for c in pw), "Missing digits"
assert any(c in _SYMBOLS for c in pw), "Missing symbols"
def test_first_char_alphanumeric(self) -> None:
for _ in range(1000):
pw = generate_password()
assert pw[0] not in _SYMBOLS, f"First char '{pw[0]}' is a symbol"
def test_minimum_length_4(self) -> None:
pw = generate_password(length=4)
assert len(pw) == 4
class TestGenerateHexSecret:
def test_returns_hex_string(self) -> None:
secret = generate_hex_secret(length=32)
assert re.match(r"^[0-9a-f]+$", secret)
def test_correct_length(self) -> None:
secret = generate_hex_secret(length=20)
assert len(secret) == 20
def test_empty_for_zero(self) -> None:
secret = generate_hex_secret(length=0)
assert secret == ""
+110
View File
@@ -0,0 +1,110 @@
"""Unit tests for devx.utils.json_registry."""
from __future__ import annotations
from pathlib import Path
import pytest
from devx.utils.json_registry import JsonRegistry
class TestJsonRegistry:
def test_add_and_get(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("item1", host="10.0.0.1", user="deploy")
info = reg.get("item1")
assert info is not None
assert info["host"] == "10.0.0.1"
assert info["user"] == "deploy"
assert "created_at" in info
def test_get_nonexistent(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
assert reg.get("nope") is None
def test_remove(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("item1", host="10.0.0.1")
reg.remove("item1")
assert reg.get("item1") is None
def test_remove_nonexistent_is_noop(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.remove("nonexistent") # should not raise
def test_list(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("a", host="1.1.1.1")
reg.add("b", host="2.2.2.2")
items = reg.list()
assert set(items.keys()) == {"a", "b"}
assert items["a"]["host"] == "1.1.1.1"
def test_list_empty(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
assert reg.list() == {}
def test_update_existing(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("item", host="1.1.1.1", status="active")
reg.update("item", status="inactive")
info = reg.get("item")
assert info["status"] == "inactive"
assert info["host"] == "1.1.1.1" # unchanged
def test_update_nonexistent_raises(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
with pytest.raises(KeyError):
reg.update("nonexistent", host="1.1.1.1")
def test_update_skips_none_values(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("item", host="1.1.1.1")
reg.update("item", host=None, status="active")
info = reg.get("item")
assert info["host"] == "1.1.1.1" # not overwritten by None
assert info["status"] == "active"
def test_persistence_across_instances(self, tmp_path: Path) -> None:
path = tmp_path / "state.json"
reg1 = JsonRegistry(path)
reg1.add("item", host="10.0.0.1")
reg2 = JsonRegistry(path)
info = reg2.get("item")
assert info is not None
assert info["host"] == "10.0.0.1"
def test_overwrite_existing(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("item", host="1.1.1.1")
reg.add("item", host="2.2.2.2")
info = reg.get("item")
assert info["host"] == "2.2.2.2"
def test_corrupt_json_returns_empty(self, tmp_path: Path) -> None:
path = tmp_path / "state.json"
path.write_text("{invalid json")
reg = JsonRegistry(path)
assert reg.list() == {}
def test_nonexistent_file_returns_empty(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "nonexistent.json")
assert reg.list() == {}
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
path = tmp_path / "subdir" / "deeper" / "state.json"
reg = JsonRegistry(path)
reg.add("item", host="1.1.1.1")
assert path.exists()
def test_get_returns_copy(self, tmp_path: Path) -> None:
reg = JsonRegistry(tmp_path / "state.json")
reg.add("item", host="1.1.1.1", tags=["a", "b"])
info = reg.get("item")
assert info is not None
info["tags"].append("c")
# Original should be unchanged
info2 = reg.get("item")
assert info2 is not None
assert info2["tags"] == ["a", "b"]
+53
View File
@@ -0,0 +1,53 @@
"""Unit tests for devx.utils.logging."""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
from devx.utils.logging import get_logger
class TestGetLogger:
def test_returns_logger_with_handlers(self) -> None:
logger = get_logger("test_devx_unit_1")
assert logger.handlers
assert isinstance(logger.handlers[0], logging.FileHandler)
def test_idempotent(self) -> None:
logger1 = get_logger("test_devx_unit_2")
initial_count = len(logger1.handlers)
logger2 = get_logger("test_devx_unit_2")
assert logger1 is logger2
assert len(logger2.handlers) == initial_count
def test_log_level_is_debug(self) -> None:
logger = get_logger("test_devx_unit_3")
assert logger.level == logging.DEBUG
def test_file_handler_level_is_debug(self) -> None:
logger = get_logger("test_devx_unit_4")
file_handler = logger.handlers[0]
assert file_handler.level == logging.DEBUG
def test_default_name(self) -> None:
logger = get_logger()
assert logger.name == "devx"
def test_creates_log_directory(self, tmp_path: Path) -> None:
with patch.object(Path, "home", return_value=tmp_path):
get_logger("test_app_creates_dir")
log_dir = tmp_path / ".local" / "state" / "test_app_creates_dir" / "logs"
assert log_dir.exists()
assert (log_dir / "test_app_creates_dir.log").exists()
def test_formatter_includes_timestamp(self) -> None:
logger = get_logger("test_devx_unit_5")
file_handler = logger.handlers[0]
fmt = file_handler.formatter
assert fmt is not None
assert "%(asctime)s" in fmt._fmt
assert "%(levelname)s" in fmt._fmt
assert "%(name)s" in fmt._fmt
assert "%(message)s" in fmt._fmt
+71
View File
@@ -0,0 +1,71 @@
"""Unit tests for devx.utils.network."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
import requests
from devx.utils.network import check_http_connectivity, wait_for_ssh
_no_sleep = MagicMock()
class TestCheckHttpConnectivity:
@patch("devx.utils.network.requests.get")
def test_success(self, mock_get: MagicMock) -> None:
mock_get.return_value = MagicMock(status_code=200)
check_http_connectivity("https://example.com", max_attempts=3)
mock_get.assert_called_once()
@patch("devx.utils.network.requests.get")
def test_retries_on_connection_error(self, mock_get: MagicMock) -> None:
mock_get.side_effect = [
requests.exceptions.ConnectionError("refused"),
requests.exceptions.ConnectionError("refused"),
MagicMock(status_code=200),
]
check_http_connectivity("https://example.com", max_attempts=5, sleep=_no_sleep)
assert mock_get.call_count == 3
@patch("devx.utils.network.requests.get")
def test_raises_after_max_attempts(self, mock_get: MagicMock) -> None:
mock_get.side_effect = requests.exceptions.ConnectionError("refused")
with pytest.raises(requests.exceptions.ConnectionError):
check_http_connectivity("https://example.com", max_attempts=2, sleep=_no_sleep)
assert mock_get.call_count == 2
@patch("devx.utils.network.requests.get")
def test_verify_false(self, mock_get: MagicMock) -> None:
mock_get.return_value = MagicMock(status_code=200)
check_http_connectivity("https://example.com", verify=False)
mock_get.assert_called_once_with("https://example.com", timeout=10, verify=False)
class TestWaitForSsh:
@patch("devx.utils.network.socket.create_connection")
def test_immediate_success(self, mock_conn: MagicMock) -> None:
mock_conn.return_value.__enter__ = MagicMock()
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
wait_for_ssh("10.0.0.1")
mock_conn.assert_called_once()
@patch("devx.utils.network.socket.create_connection")
def test_retries_until_success(self, mock_conn: MagicMock) -> None:
mock_conn.side_effect = [
OSError("refused"),
OSError("refused"),
MagicMock(),
]
mock_conn.return_value.__enter__ = MagicMock()
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
wait_for_ssh("10.0.0.1", max_attempts=5, sleep=_no_sleep)
assert mock_conn.call_count == 3
@patch("devx.utils.network.socket.create_connection")
def test_timeout_after_max_attempts(self, mock_conn: MagicMock) -> None:
mock_conn.side_effect = OSError("refused")
with pytest.raises(RuntimeError, match="SSH not available"):
wait_for_ssh("10.0.0.1", max_attempts=3, sleep=_no_sleep)
assert mock_conn.call_count == 3
+96
View File
@@ -0,0 +1,96 @@
"""Unit tests for devx.utils.ssh."""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from devx.utils.ssh import docker_exec_on_vm, ssh_exec, wait_for_ssh
class TestSshExec:
@patch("devx.utils.ssh.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
result = ssh_exec("10.0.0.1", "uname -a")
assert result.returncode == 0
mock_run.assert_called_once()
@patch("devx.utils.ssh.subprocess.run")
def test_failure_with_check(self, mock_run: MagicMock) -> None:
mock_result = MagicMock(returncode=1, stdout="", stderr="error")
mock_result.check_returncode.side_effect = subprocess.CalledProcessError(1, "ssh")
mock_run.return_value = mock_result
with pytest.raises(subprocess.CalledProcessError):
ssh_exec("10.0.0.1", "false")
@patch("devx.utils.ssh.subprocess.run")
def test_failure_without_check(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
result = ssh_exec("10.0.0.1", "false", check=False)
assert result.returncode == 1
@patch("devx.utils.ssh.subprocess.run")
def test_custom_user(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
ssh_exec("10.0.0.1", "whoami", user="root")
cmd = mock_run.call_args[0][0]
assert "root@10.0.0.1" in cmd
class TestDockerExecOnVm:
@patch("devx.utils.ssh.ssh_exec")
def test_simple_command(self, mock_ssh: MagicMock) -> None:
mock_ssh.return_value = MagicMock(stdout="output\n")
result = docker_exec_on_vm("10.0.0.1", "mycontainer", "ls /")
assert result == "output"
mock_ssh.assert_called_once_with("10.0.0.1", "docker exec mycontainer ls /", user="deploy", timeout=30)
@patch("devx.utils.ssh.ssh_exec")
def test_psql_mode(self, mock_ssh: MagicMock) -> None:
mock_ssh.return_value = MagicMock(stdout="result\n")
result = docker_exec_on_vm("10.0.0.1", "db", "SELECT 1", db_user="postgres", db_name="mydb")
assert result == "result"
call_args = mock_ssh.call_args[0][1]
assert "psql -U postgres -d mydb" in call_args
assert "SELECT 1" in call_args
@patch("devx.utils.ssh.ssh_exec")
def test_psql_escapes_single_quotes(self, mock_ssh: MagicMock) -> None:
mock_ssh.return_value = MagicMock(stdout="\n")
docker_exec_on_vm("10.0.0.1", "db", "SELECT 'it''s ok'", db_user="pg", db_name="db")
call_args = mock_ssh.call_args[0][1]
assert "'\"'\"'" in call_args
class TestWaitForSsh:
@patch("devx.utils.ssh.socket.create_connection")
def test_immediate_success(self, mock_conn: MagicMock) -> None:
mock_conn.return_value.__enter__ = MagicMock()
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
wait_for_ssh("10.0.0.1")
mock_conn.assert_called_once()
@patch("devx.utils.ssh.socket.create_connection")
@patch("devx.utils.ssh.time.sleep")
def test_retries_until_success(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
# Fail twice, then succeed
mock_conn.side_effect = [
OSError("refused"),
OSError("refused"),
MagicMock(),
]
mock_conn.return_value.__enter__ = MagicMock()
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
wait_for_ssh("10.0.0.1", max_attempts=5)
assert mock_conn.call_count == 3
@patch("devx.utils.ssh.socket.create_connection")
@patch("devx.utils.ssh.time.sleep")
def test_timeout_after_max_attempts(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
mock_conn.side_effect = OSError("refused")
with pytest.raises(RuntimeError, match="SSH not available"):
wait_for_ssh("10.0.0.1", max_attempts=3)
assert mock_conn.call_count == 3
+124
View File
@@ -0,0 +1,124 @@
"""Unit tests for devx.utils.step_tracker."""
from __future__ import annotations
import click
import pytest
from click.testing import CliRunner
from devx.utils.step_tracker import Step, StepTracker, track_steps
class TestStep:
def test_initial_status_is_pending(self) -> None:
step = Step("install")
assert step.status == "pending"
assert step.name == "install"
class TestStepTracker:
def test_begin_adds_step_as_in_progress(self) -> None:
tracker = StepTracker()
tracker.begin("install deps")
assert len(tracker.steps) == 1
assert tracker.steps[0].status == "in_progress"
def test_done_marks_last_in_progress_as_completed(self) -> None:
tracker = StepTracker()
tracker.begin("step1")
tracker.done()
assert tracker.steps[0].status == "completed"
def test_done_no_op_if_no_in_progress(self) -> None:
tracker = StepTracker()
tracker.begin("step1")
tracker.done()
tracker.done() # should not raise, no-op
assert tracker.steps[0].status == "completed"
def test_done_no_op_if_empty(self) -> None:
tracker = StepTracker()
tracker.done() # should not raise
def test_multiple_steps(self) -> None:
tracker = StepTracker()
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
tracker.done()
assert len(tracker.steps) == 2
assert tracker.steps[0].status == "completed"
assert tracker.steps[1].status == "completed"
class TestTrackSteps:
def test_successful_operation(self) -> None:
runner = CliRunner()
with runner.isolation():
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
tracker.done()
assert len(tracker.steps) == 2
assert all(s.status == "completed" for s in tracker.steps)
def test_exception_marks_in_progress_as_failed(self) -> None:
runner = CliRunner()
with runner.isolation():
with pytest.raises(ValueError, match="boom"):
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
raise ValueError("boom")
assert tracker.steps[0].status == "completed"
assert tracker.steps[1].status == "failed"
def test_pending_step_stays_pending_on_exception(self) -> None:
runner = CliRunner()
with runner.isolation():
with pytest.raises(ValueError):
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
tracker.done()
tracker.begin("step3") # in_progress
# step4 is pending (not started)
raise ValueError("oops")
assert tracker.steps[2].status == "failed"
def test_empty_operation(self) -> None:
runner = CliRunner()
with runner.isolation():
with track_steps() as tracker:
pass
assert tracker.steps == []
def test_report_printed_on_success(self) -> None:
runner = CliRunner()
result = runner.invoke(_cmd_success, [], color=False)
assert result.exit_code == 0
assert "Operation Report" in result.output
assert "step1" in result.output
def test_report_printed_on_failure(self) -> None:
runner = CliRunner()
result = runner.invoke(_cmd_failure, [], color=False)
assert result.exit_code != 0
assert "Operation Report" in result.output
@click.command()
def _cmd_success() -> None:
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
@click.command()
def _cmd_failure() -> None:
with track_steps() as tracker:
tracker.begin("step1")
raise ValueError("oops")
+134
View File
@@ -0,0 +1,134 @@
"""Unit tests for devx.utils.vault."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from devx.utils.vault import (
decrypt_file,
encrypt_file,
is_encrypted,
load_vault_yaml,
save_vault_yaml,
)
class TestIsEncrypted:
def test_encrypted_file(self, tmp_path: Path) -> None:
f = tmp_path / "secret.yml"
f.write_text("$ANSIBLE_VAULT;1.1;AES256\n9382928...\n")
assert is_encrypted(f) is True
def test_plain_file(self, tmp_path: Path) -> None:
f = tmp_path / "plain.yml"
f.write_text("key: value\n")
assert is_encrypted(f) is False
class TestLoadVaultYaml:
def test_plain_yaml_no_vault_pass(self, tmp_path: Path) -> None:
f = tmp_path / "data.yml"
f.write_text("key: value\nlist:\n - a\n - b\n")
data = load_vault_yaml(f)
assert data == {"key": "value", "list": ["a", "b"]}
def test_empty_file(self, tmp_path: Path) -> None:
f = tmp_path / "empty.yml"
f.write_text("")
data = load_vault_yaml(f)
assert data == {}
def test_vault_pass_not_exists(self, tmp_path: Path) -> None:
f = tmp_path / "data.yml"
f.write_text("key: value\n")
data = load_vault_yaml(f, vault_pass=tmp_path / "nonexistent")
assert data == {"key": "value"}
@patch("devx.utils.vault.subprocess.run")
def test_encrypted_file_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
f = tmp_path / "secret.yml"
f.write_text("$ANSIBLE_VAULT\n...")
vp = tmp_path / "vault-password"
vp.write_text("secret")
mock_run.return_value = MagicMock(returncode=0, stdout="key: decrypted\n", stderr="")
data = load_vault_yaml(f, vault_pass=vp)
assert data == {"key": "decrypted"}
@patch("devx.utils.vault.subprocess.run")
def test_not_vault_encrypted_fallback(self, mock_run: MagicMock, tmp_path: Path) -> None:
f = tmp_path / "plain.yml"
f.write_text("key: value\n")
vp = tmp_path / "vault-password"
vp.write_text("secret")
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="is not vault encrypted")
data = load_vault_yaml(f, vault_pass=vp)
assert data == {"key": "value"}
class TestSaveVaultYaml:
def test_save_plain(self, tmp_path: Path) -> None:
f = tmp_path / "output.yml"
save_vault_yaml(f, {"key": "value"})
content = f.read_text()
assert "key: value" in content
def test_save_with_vault_pass_not_exists(self, tmp_path: Path) -> None:
f = tmp_path / "output.yml"
vp = tmp_path / "nonexistent"
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
# Should save as plain YAML
content = f.read_text()
assert "key: value" in content
assert "$ANSIBLE_VAULT" not in content
@patch("devx.utils.vault.subprocess.run")
def test_save_and_encrypt(self, mock_run: MagicMock, tmp_path: Path) -> None:
f = tmp_path / "output.yml"
vp = tmp_path / "vault-password"
vp.write_text("secret")
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
# File should be written
assert f.exists()
# ansible-vault encrypt should be called
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "ansible-vault" in cmd
assert "encrypt" in cmd
class TestEncryptFile:
@patch("devx.utils.vault.subprocess.run")
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
f = tmp_path / "file.yml"
f.write_text("key: value")
vp = tmp_path / "vault-password"
vp.write_text("secret")
encrypt_file(f, vp)
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "ansible-vault" in cmd
assert "encrypt" in cmd
assert str(f) in cmd
assert str(vp) in cmd
class TestDecryptFile:
@patch("devx.utils.vault.subprocess.run")
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
f = tmp_path / "file.yml"
f.write_text("$ANSIBLE_VAULT\n...")
vp = tmp_path / "vault-password"
vp.write_text("secret")
decrypt_file(f, vp)
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "ansible-vault" in cmd
assert "decrypt" in cmd
assert str(f) in cmd
assert str(vp) in cmd
+70
View File
@@ -0,0 +1,70 @@
"""Unit tests for devx.ci.validate_deploy_ref."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.validate_deploy_ref import main
class TestValidateDeployRef:
def test_valid_tag_prints_ref(self, tmp_path: Path) -> None:
runner = CliRunner()
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef1234567890\n", stderr="")
result = runner.invoke(main, ["--tag", "v1.0.0"])
assert result.exit_code == 0
assert "v1.0.0" in result.output
def test_invalid_tag_exits_nonzero(self) -> None:
runner = CliRunner()
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
result = runner.invoke(main, ["--tag", "nonexistent"])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_no_tag_without_allow_empty_exits_nonzero(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 1
assert "No tag specified" in result.output
def test_allow_empty_prints_pr_mode(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--allow-empty"])
assert result.exit_code == 0
assert "PR mode" in result.output
def test_github_output_writes_ref(self, tmp_path: Path) -> None:
runner = CliRunner()
gh_output = tmp_path / "github_output"
gh_output.write_text("")
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
assert result.exit_code == 0
content = gh_output.read_text()
assert "deploy-ref=v1.0.0" in content
def test_github_output_without_env_var_exits_nonzero(self) -> None:
runner = CliRunner()
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
with runner.isolation(env={"GITHUB_OUTPUT": ""}):
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
assert result.exit_code == 1
assert "GITHUB_OUTPUT" in result.output
def test_allow_empty_with_github_output(self, tmp_path: Path) -> None:
runner = CliRunner()
gh_output = tmp_path / "github_output"
gh_output.write_text("")
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
result = runner.invoke(main, ["--allow-empty", "--github-output"])
assert result.exit_code == 0
assert "deploy-ref=" in gh_output.read_text()