Compare commits

..
31 Commits
Author SHA1 Message Date
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
devx-ci-bot 45a9c7d431 release: v0.35.2 [skip ci] 2026-07-06 08:45:42 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8e1c7d03a4 DEVX-118: fix: exclude .vale directory from lint_docs scanning
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 21s
Post-merge / vikunja (push) Successful in 23s
Post-merge / sync-wiki (push) Failing after 29s
Post-merge / release (push) Successful in 41s
Post-merge / publish (push) Successful in 39s
Post-merge / badges (push) Failing after 45s
Third-party Vale style packages contain README.md files with code blocks
that don't specify a language, causing false positives in lint_docs.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:44:25 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2de3ab4d84 DEVX-118: docs: update AGENTS.md with new tools and make targets
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 16s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Failing after 22s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / badges (push) Failing after 31s
Document check_doc_versions.py, Vale, and new make targets in AGENTS.md.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:29:47 +02:00
devx-ci-bot fa501adfbc release: v0.35.1 [skip ci] 2026-07-06 08:27:00 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0a5625b70b DEVX-118: refactor: rewrite sync_wiki.py to use git-based approach
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 23s
Post-merge / sync-wiki (push) Failing after 28s
Post-merge / release (push) Successful in 42s
Post-merge / publish (push) Successful in 22s
Post-merge / badges (push) Failing after 31s
Replace the unreliable Gitea wiki API with direct Git operations:
- Clone {repo}.wiki.git, copy docs with link transformation, push
- Faster: single git push vs N API calls
- More reliable: no API timeouts or rate limits
- Atomic: all pages sync in one commit
- Auto-pruning: stale wiki pages removed automatically
- Link transformation: [text](file.md) → [text](file) for wiki format
- 36 new tests covering transform_links, clone, sync_files, commit, verify

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:26:00 +02:00
devx-ci-bot f28ba432ce release: v0.35.0 [skip ci] 2026-07-06 08:16:54 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> fb342e7b9d DEVX-118: feat: enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / release (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / publish (push) Successful in 28s
Post-merge / badges (push) Failing after 36s
- Add check_single_h1: each markdown file should have at most one H1
- Add check_max_heading_depth: headings should not exceed H4 (configurable)
- Add check_line_length: warn on lines >120 chars (non-blocking — badge URLs)
- Add check_code_block_languages: fenced code blocks must specify a language
- Add check_orphan_docs: warn on docs not linked from index.md or mapping.json
- Fix all code blocks in docs to specify language (text for plain blocks)
- Fix duplicate H1 in .vale/styles/devx/README.md
- Add 18 new tests for full coverage of new checks

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:15:54 +02:00
65 changed files with 2802 additions and 1729 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 # 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) # Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens # Create at: https://work.oblachno.oblachno.fyi/settings/tokens
+14 -6
View File
@@ -38,6 +38,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
@@ -62,18 +64,22 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-release run: make setup-release
- name: Docker registry login - name: Docker registry login
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: | run: |
. .venv/bin/activate . .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 - name: Build and push tier images
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
@@ -103,7 +109,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -125,10 +131,12 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Clean up old image versions - name: Clean up old image versions
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
+18 -25
View File
@@ -16,6 +16,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Lint all - name: Lint all
run: | run: |
@@ -32,31 +34,15 @@ jobs:
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5 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: env:
PYTHONPATH: src PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
vale --minAlertLevel=error docs/ AGENTS.md README.md make devx-docs-check
- name: Translation completeness check - name: Translation completeness check
env: env:
PYTHONPATH: src PYTHONPATH: src
@@ -95,6 +81,8 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
@@ -122,10 +110,11 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Release dry-run validation - name: Release dry-run validation
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -143,10 +132,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Run automated PR review - name: Run automated PR review
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
set -euo pipefail set -euo pipefail
@@ -176,12 +167,14 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Post approval review - name: Post approval review
env: env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PYTHONPATH: src PYTHONPATH: src
@@ -196,7 +189,7 @@ jobs:
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)." --body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8" DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src PYTHONPATH: src
+29 -13
View File
@@ -47,6 +47,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
@@ -70,6 +72,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Validate latest commit message - name: Validate latest commit message
env: env:
@@ -95,10 +99,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Configure git - name: Configure git
run: | run: |
@@ -107,6 +111,7 @@ jobs:
- name: Run release - name: Run release
id: release-tag id: release-tag
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -115,7 +120,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -142,10 +147,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
ref: ${{ needs.release.outputs.tag }} ref: ${{ needs.release.outputs.tag }}
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image EXTRAS=release run: make setup-image EXTRAS=release
- name: Build and publish release - name: Build and publish release
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -154,7 +161,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -183,18 +190,20 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Sync documentation to wiki - name: Sync documentation to wiki
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -219,15 +228,18 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
ref: master ref: master
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Fetch latest master - name: Fetch latest master
run: | run: |
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Generate and push badges - name: Generate and push badges
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -235,7 +247,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -260,6 +272,8 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Update Vikunja task - name: Update Vikunja task
env: env:
@@ -272,7 +286,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -295,10 +309,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Ensure branch protection and labels - name: Ensure branch protection and labels
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
DEVX_REPO_NAME: devx DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss DEVX_REPO_OWNER: oblachno-oss
@@ -308,7 +324,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
+4 -10
View File
@@ -73,18 +73,12 @@ repos:
pass_filenames: false pass_filenames: false
stages: [pre-commit] stages: [pre-commit]
- id: doc-coverage - id: docs-check
name: documentation coverage check name: documentation gate (coverage + stale refs + lint + version refs + prose)
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_VALE_LEVEL=warning make devx-docs-check'
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 .
language: system language: system
pass_filenames: false pass_filenames: false
always_run: true
stages: [pre-commit] stages: [pre-commit]
- id: pytest-cov - id: pytest-cov
+4 -1
View File
@@ -32,14 +32,17 @@ write-good.E-Prime = NO
write-good.So = NO write-good.So = NO
write-good.ThereIs = NO write-good.ThereIs = NO
write-good.TooWordy = NO write-good.TooWordy = NO
write-good.Passive = NO
# Vale defaults — spelling catches too many technical terms # Vale defaults — spelling catches too many technical terms
Vale.Terms = NO Vale.Terms = NO
Vale.Repetition = NO Vale.Repetition = NO
Vale.Spelling = 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.FleschReadingEase = suggestion
Readability.FleschKincaid = suggestion
Readability.AutomatedReadability = suggestion
Readability.ColemanLiau = suggestion Readability.ColemanLiau = suggestion
Readability.LIX = suggestion Readability.LIX = suggestion
Readability.GunningFog = 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 level: warning
scope: raw scope: raw
raw: raw:
- '(?s)```\n(?!.*```)' - '(?ms)^\n```\n.*?^```\s*$'
+2 -1
View File
@@ -1,2 +1,3 @@
# Custom Vale style for devx documentation # Custom Vale style for devx documentation
# Project-specific terminology and style rules
Project-specific terminology and style rules
+13 -10
View File
@@ -18,19 +18,21 @@ venv activation automatically — always prefer `make <target>` over raw command
```bash ```bash
make setup # Create venv, install deps, set up hooks, install CI tools make setup # Create venv, install deps, set up hooks, install CI tools
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint, vale to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
make pytest-cov # Unit tests with 100% coverage enforcement make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage make test-unit # Unit tests without coverage
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint) make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun) make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
make workflow-check # workflow-lint + workflow-dryrun make workflow-check # workflow-lint + workflow-dryrun
make devx-check-doc-versions # Verify docs version refs match __version__
make devx-vale # Run Vale prose linter on docs and README
make clean # Remove caches, build artifacts, coverage data make clean # Remove caches, build artifacts, coverage data
``` ```
`make setup` automatically installs all development tools: `make setup` automatically installs all development tools:
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks) - **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
- **actionlint, git-cliff, act_runner, tea, hadolint** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin) - **actionlint, git-cliff, act_runner, tea, hadolint, vale** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`) - **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
## Workflow Verification (Before Push) ## Workflow Verification (Before Push)
@@ -57,7 +59,7 @@ devx is a reusable Python package providing development and CI/CD tools for obla
### Package Structure ### Package Structure
``` ```text
src/devx/ src/devx/
├── __init__.py # Version (single source of truth, read by setuptools) ├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command) ├── cli.py # Click-based CLI entry point (devx command)
@@ -86,11 +88,12 @@ src/devx/
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast │ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check │ ├── check_translations.py # Translation completeness check
│ ├── doc_coverage.py # Documentation coverage check │ ├── doc_coverage.py # Documentation coverage check
│ └── lint_docs.py # Documentation linter (structure, links, headings) │ └── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint │ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
│ ├── install_checkmake.py # Install checkmake (Makefile linter) │ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── check_doc_versions.py # Verify docs version refs match __version__
│ ├── build_image.py # Build and push Docker images to Gitea registry │ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry │ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
│ ├── check_test_speed.py # Measure unit test execution time │ ├── check_test_speed.py # Measure unit test execution time
@@ -158,7 +161,7 @@ git checkout -b DEVX-N-short-description
### 4. Commit (Conventional Commits) ### 4. Commit (Conventional Commits)
Branch commits use conventional commit format (no `DEVX-N:` prefix): Branch commits use conventional commit format (no `DEVX-N:` prefix):
``` ```text
feat: add new feature feat: add new feature
fix: resolve bug fix: resolve bug
docs: update README docs: update README
@@ -223,14 +226,14 @@ After a PR is merged to master, the **post-merge workflow**
- Pushes both the commit and tag to master - Pushes both the commit and tag to master
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL 3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not just when release succeeds), so docs-only non-release commits (not only when release succeeds), so docs-only
changes still update the wiki. changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch. 4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
Uses `if: always()` so it runs on every push, including release commits. Uses `if: always()` so it runs on every push, including release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL 5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not just when release succeeds), so infrastructure-only non-release commits (not only when release succeeds), so infrastructure-only
changes still update the task tracker. changes still update the task tracker.
6. **publish** — Runs after release succeeds (needs: release). Builds and 6. **publish** — Runs after release succeeds (needs: release). Builds and
@@ -395,7 +398,7 @@ system loads `.env` automatically via `python-dotenv`.
### pyproject.toml [tool.devx] Configuration ### 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 the `[tool.devx]` section in `pyproject.toml`. This allows per-project
customization without environment variables. customization without environment variables.
@@ -607,7 +610,7 @@ the user should not need to specify which profile to use.
2. **Background by default, foreground when blocking.** 2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history. 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. 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.** 6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small. 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. All notable changes to this project will be documented in this file.
## [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
- Exclude .vale directory from lint_docs scanning
## [0.35.1] - 2026-07-06
### Refactor
- Rewrite sync_wiki.py to use git-based approach
## [0.35.0] - 2026-07-06
### Features
- Enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
## [0.34.0] - 2026-07-06 ## [0.34.0] - 2026-07-06
### Features ### Features
+11 -11
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) [![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) [![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) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.34.0", "devx>=0.38.0",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.34.0"`) or use a version constraint > `dependencies` (for example, `"devx==0.38.0"`) or use a version constraint
> (for example, `"devx>=0.34.0,<0.35"`). > (for example, `"devx>=0.38.0,<0.39"`).
### Optional extras ### Optional extras
@@ -372,7 +372,7 @@ infrastructure = []
# Files that would default to user-facing but are actually infrastructure # Files that would default to user-facing but are actually infrastructure
infrastructure_overrides = [ infrastructure_overrides = [
"src/myproject/__init__.py", # only contains __version__ "src/myproject/__init__.py", # example only — only contains __version__
] ]
# Safety override for broad infrastructure patterns # Safety override for broad infrastructure patterns
@@ -434,7 +434,7 @@ devx is a self-contained Python package under `src/devx/`. It never imports
from scripts outside the package. All tools are invoked via from scripts outside the package. All tools are invoked via
`python -m devx.ci.*`, `python -m devx.tools.*`, or `python -m devx.molecule.*`. `python -m devx.ci.*`, `python -m devx.tools.*`, or `python -m devx.molecule.*`.
``` ```text
src/devx/ src/devx/
├── __init__.py # Version (single source of truth, read by setuptools) ├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command) ├── cli.py # Click-based CLI entry point (devx command)
+1 -1
View File
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx && rm -rf /tmp/devx
# Install CI/CD binary tools # 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 && python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter) # 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) [![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) [![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) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/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/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.34.0", "devx>=0.38.0",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.34.0"` or `"devx>=0.34.0,<0.35"`. Pin a specific version if needed: `"devx==0.38.0"` or `"devx>=0.38.0,<0.39"`.
### Optional extras ### Optional extras
+9 -7
View File
@@ -6,7 +6,7 @@ from scripts outside the package.
## Package structure ## Package structure
``` ```text
src/devx/ src/devx/
├── __init__.py # Version (single source of truth, read by setuptools) ├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command) ├── cli.py # Click-based CLI entry point (devx command)
@@ -122,7 +122,9 @@ exponential backoff (2s, 4s, 8s).
- Labels (list, create, add to issues) - Labels (list, create, add to issues)
- Issues (create, list) - Issues (create, list)
- Pull requests (get commits, merge, create review) - 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) - Wiki pages (list, fetch, create, update, delete)
**`VikunjaClient`** — Vikunja REST API wrapper: **`VikunjaClient`** — Vikunja REST API wrapper:
@@ -309,7 +311,7 @@ from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
### `setup.py` ### `setup.py`
Project setup: installs Python dependencies (editable mode with extras), 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 hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
profile from `.env`. Supports `--extras` to specify dependency groups, profile from `.env`. Supports `--extras` to specify dependency groups,
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea `--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
@@ -439,7 +441,7 @@ v2 failures. Supports loading custom platforms from a JSON file.
### PR lifecycle ### PR lifecycle
``` ```text
Developer creates Vikunja task (DEVX-N) Developer creates Vikunja task (DEVX-N)
@@ -475,7 +477,7 @@ CI workflow (ci.yml) triggers:
### Post-merge flow ### Post-merge flow
``` ```text
Push to master (squash-merge commit: "DEVX-N <conventional commit>") Push to master (squash-merge commit: "DEVX-N <conventional commit>")
@@ -519,7 +521,7 @@ Post-merge workflow (post-merge.yml) triggers:
### Publish flow ### Publish flow
``` ```text
Tag push (vX.Y.Z) triggers publish workflow (publish.yml): Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
@@ -536,7 +538,7 @@ Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
### Badge generation flow ### Badge generation flow
``` ```text
push_badges.py: push_badges.py:
├── fetch_latest_master() → git fetch + reset --hard origin/master ├── fetch_latest_master() → git fetch + reset --hard origin/master
+3 -3
View File
@@ -6,7 +6,7 @@ tag-triggered publishing.
## Workflow overview ## Workflow overview
``` ```text
PR opened/synchronized ──► CI (ci.yml) PR opened/synchronized ──► CI (ci.yml)
│ ├── quality │ ├── quality
│ ├── detect-changes │ ├── detect-changes
@@ -143,7 +143,7 @@ updates.
### Job dependency graph ### Job dependency graph
``` ```text
detect-type ──┬── validate-commit-msg (skip if release commit) detect-type ──┬── validate-commit-msg (skip if release commit)
├── release (skip if release commit) ├── release (skip if release commit)
│ │ │ │
@@ -251,7 +251,7 @@ badges using `python -m devx.ci.push_badges`:
1. **Fetch latest master** — `git fetch origin master && git reset --hard 1. **Fetch latest master** — `git fetch origin master && git reset --hard
origin/master` (ensures the version badge reflects the current state, origin/master` (ensures the version badge reflects the current state,
even if the release job just pushed a new version) even if the release job recently pushed a new version)
2. **Generate badges** — calls `devx.tools.generate_badges` which runs 2. **Generate badges** — calls `devx.tools.generate_badges` which runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`, SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
+1 -1
View File
@@ -405,7 +405,7 @@ devx tools install-tools --list # list status
### `devx tools setup` ### `devx tools setup`
Project setup: install Python dependencies (editable mode with extras), 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 hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
profile from `.env`. profile from `.env`.
+3 -3
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.34.0", "devx>=0.38.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.34.0", "devx>=0.38.0",
] ]
``` ```
@@ -72,7 +72,7 @@ tea CLI, etc.) and configure pre-commit hooks.
devx expects a `docs/` directory with at minimum: devx expects a `docs/` directory with at minimum:
``` ```text
docs/ docs/
├── index.md # Documentation home page ├── index.md # Documentation home page
├── mapping.json # Wiki page title mappings ├── mapping.json # Wiki page title mappings
+6
View File
@@ -121,6 +121,12 @@ vikunja_project_id = 8
repo_owner = "oblachno-oss" repo_owner = "oblachno-oss"
repo_name = "devx" repo_name = "devx"
[tool.devx.check_agent_docs]
skip_ref_prefixes = [
"src/myproject/",
"ansible/requirements.yml",
]
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns) # 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe) # 4. Default: user-facing (safe)
[tool.devx.classify] [tool.devx.classify]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.34.0" __version__ = "0.38.0"
+30
View File
@@ -372,6 +372,36 @@ class GiteaClient:
r = self._request("GET", f"/actions/jobs/{job_id}/logs") r = self._request("GET", f"/actions/jobs/{job_id}/logs")
return r.text 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: class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling.""" """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. while the squashed commit follows conventional commits.
Usage: 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 import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -40,6 +39,7 @@ from devx.config import (
) )
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ 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. # 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*") _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. Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.")) 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) client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1 page = 1
while True: while True:
@@ -197,9 +200,10 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
@click.argument("repo") @click.argument("repo")
@click.argument("pr_number") @click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
# Validate PR number is an integer # Validate PR number is an integer
try: try:
+17 -9
View File
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
Usage:: 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 \\ python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\ --branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\ --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 without CI secrets. In CI, the token is always set and the check is
mandatory. 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). branch-name and PR-title-format checks run (local mode).
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import click import click
@@ -55,6 +54,7 @@ from devx.config import (
) )
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token, get_vikunja_token
load_dotenv() 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: def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API. """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", "") try:
if not token or "/" not in repo: token = get_ci_token()
except click.ClickException:
return None
if "/" not in repo:
return None return None
owner, repo_name = repo.split("/", 1) owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) 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 raise when ``VIKUNJA_TOKEN`` is missing it returns ``None`` so the
caller can skip the check in local mode. caller can skip the check in local mode.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
except click.ClickException:
return None return None
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
from devx.config import DEFAULT_PER_PAGE from devx.config import DEFAULT_PER_PAGE
@@ -221,7 +225,11 @@ def cli(
if not skip_vikunja: if not skip_vikunja:
vikunja_title = get_vikunja_title_optional(task_id) vikunja_title = get_vikunja_title_optional(task_id)
if vikunja_title is None: 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: if token_set:
errors.append( errors.append(
_( _(
+1
View File
@@ -185,6 +185,7 @@ def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
# Try common locations # Try common locations
candidates = [ candidates = [
root / "src" / "devx" / "translations.json", root / "src" / "devx" / "translations.json",
root / "src" / "grm" / "translations.json",
] ]
# Also search for any translations.json in src/ # Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"): 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.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -96,7 +97,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
return total 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. """Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default. Tries the Gitea API first, then falls back to env vars, then default.
@@ -152,7 +153,10 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+63 -8
View File
@@ -21,6 +21,7 @@ from pathlib import Path
import click import click
from devx.config import _load_pyproject_devx
from devx.i18n import _ from devx.i18n import _
# Default to the current working directory (consuming repo's root) # 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 # Matches @cli.command, @ci.command, @tools.command, @molecule.command
for match in re.finditer(r"@\w+\.command\b", content): for match in re.finditer(r"@\w+\.command\b", content):
# Check for explicit name="..." in the decorator arguments # 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] 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: if name_match:
commands.append(name_match.group(1)) commands.append(name_match.group(1))
continue 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 # Find the next def statement after this decorator
after = content[decorator_end:] after = content[decorator_end:]
def_match = re.search(r"def\s+(\w+)\s*\(", after) 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.command()
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).") @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("--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( @click.option(
"--fail-on-missing", "--fail-on-missing",
is_flag=True, is_flag=True,
default=False, default=False,
help="Exit with non-zero status if any documentation is missing.", 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() root = Path.cwd()
docs_path = Path(docs_dir) if docs_dir else root / "docs" 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 # Auto-detect source directory
if source_dir: if source_dir:
src_path = Path(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}") missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md # 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...")) 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_scripts_dir is not None:
if ci_dir.exists(): # Explicit config — empty string means skip CI script checks
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py") 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: 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) total += len(detected_scripts)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else "" ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in detected_scripts: for script in detected_scripts:
+6 -2
View File
@@ -17,7 +17,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. 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). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -41,6 +41,7 @@ from devx.i18n import _
from devx.molecule.molecule_ci_guard import ( from devx.molecule.molecule_ci_guard import (
poll_for_other_failures, poll_for_other_failures,
) )
from devx.tokens import get_ci_token
POLL_INTERVAL = 10 POLL_INTERVAL = 10
@@ -50,7 +51,10 @@ POLL_INTERVAL = 10
def cli(pytest_args: tuple[str, ...]) -> None: def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection.""" """Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "") 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")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests") job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
+174 -2
View File
@@ -7,6 +7,12 @@ Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
- **Broken internal links**: relative paths and anchors in markdown files - **Broken internal links**: relative paths and anchors in markdown files
must resolve to actual files and headings. must resolve to actual files and headings.
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``). - **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
- **Single H1**: each markdown file should have at most one H1 heading.
- **Max heading depth**: headings should not exceed H4 (configurable).
- **Max line length**: lines should not exceed 120 characters (configurable).
- **Code block language**: fenced code blocks should specify a language.
- **Orphan docs**: docs not linked from index.md or mapping.json (warning).
- **Mapping completeness**: all docs/*.md should be in mapping.json (warning).
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation. - **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
- **Stale docs**: files not modified in >180 days (warning only). - **Stale docs**: files not modified in >180 days (warning only).
- **Trailing whitespace**: lines should not end with whitespace. - **Trailing whitespace**: lines should not end with whitespace.
@@ -49,6 +55,15 @@ REQUIRED_DOC_FILES = ["index.md"]
# Maximum age for docs before they're considered stale (days) # Maximum age for docs before they're considered stale (days)
STALE_THRESHOLD_DAYS = 180 STALE_THRESHOLD_DAYS = 180
# Maximum heading depth (H4 by default)
MAX_HEADING_DEPTH = 4
# Maximum line length
MAX_LINE_LENGTH = 120
# Code block without language: ``` followed by optional whitespace only
_CODE_BLOCK_NO_LANG_RE = re.compile(r"^```[ \t]*$", re.MULTILINE)
# Files excluded from duplicate heading checks (auto-generated or structured # Files excluded from duplicate heading checks (auto-generated or structured
# with repeated subsections under different parent sections) # with repeated subsections under different parent sections)
DUPLICATE_HEADING_EXCLUDES = { DUPLICATE_HEADING_EXCLUDES = {
@@ -70,6 +85,7 @@ _EXCLUDE_DIRS = {
".pytest_cache", ".pytest_cache",
".devin", ".devin",
".terraform", ".terraform",
".vale",
"site-packages", "site-packages",
"dist-info", "dist-info",
} }
@@ -318,6 +334,120 @@ def check_duplicate_headings(root: Path) -> list[str]:
return issues return issues
def check_single_h1(root: Path) -> list[str]:
"""Check that each markdown file has at most one H1 heading."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
continue
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
h1_count = len(re.findall(r"^#\s+", content, re.MULTILINE))
if h1_count > 1:
issues.append(f"{rel_path}: {h1_count} H1 headings — should have at most 1")
return issues
def check_max_heading_depth(root: Path) -> list[str]:
"""Check that headings don't exceed MAX_HEADING_DEPTH."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
for match in re.finditer(r"^(#{1,6})\s+", content, re.MULTILINE):
level = len(match.group(1))
if level > MAX_HEADING_DEPTH:
line_num = content[: match.start()].count("\n") + 1
issues.append(f"{rel_path}:{line_num}: heading depth H{level} exceeds max H{MAX_HEADING_DEPTH}")
return issues
def check_line_length(root: Path) -> list[str]:
"""Check that no lines exceed MAX_LINE_LENGTH characters."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for i, line in enumerate(content.splitlines(), 1):
if len(line) > MAX_LINE_LENGTH:
issues.append(f"{rel_path}:{i}: line too long ({len(line)} > {MAX_LINE_LENGTH} chars)")
return issues
def check_code_block_languages(root: Path) -> list[str]:
"""Check that fenced code blocks specify a language."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
in_code_block = False
for i, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("```"):
if not in_code_block:
# Opening fence — check for language
if _CODE_BLOCK_NO_LANG_RE.match(line):
issues.append(f"{rel_path}:{i}: code block without language specifier")
in_code_block = True
else:
# Closing fence
in_code_block = False
return issues
def check_orphan_docs(root: Path, docs_dir: Path) -> list[str]:
"""Check for docs not linked from index.md or mapping.json (warnings)."""
issues: list[str] = []
if not docs_dir.is_dir():
return issues
# Collect all referenced files from index.md and mapping.json
referenced: set[str] = set()
index_file = docs_dir / "index.md"
if index_file.exists():
content = index_file.read_text(encoding="utf-8")
for match in _LINK_RE.finditer(content):
url = match.group(2).strip()
if not url.startswith(("http://", "https://", "mailto:")):
referenced.add(url.split("#")[0])
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
if isinstance(mapping, dict):
# Add both keys (filenames) and values (wiki page names)
for k, v in mapping.items():
if isinstance(k, str):
referenced.add(k)
if isinstance(v, str):
referenced.add(v)
except (json.JSONDecodeError, AttributeError):
pass
# Check each doc file
for md_file in sorted(docs_dir.rglob("*.md")):
if md_file.name == "index.md":
continue
rel_path = md_file.relative_to(docs_dir).as_posix()
if rel_path not in referenced and md_file.name not in referenced:
issues.append(f"docs/{rel_path}: orphan doc — not linked from index.md or mapping.json")
return issues
@click.command() @click.command()
@click.option("--root", default=".", help="Repository root directory.") @click.option("--root", default=".", help="Repository root directory.")
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).") @click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
@@ -327,6 +457,11 @@ def check_duplicate_headings(root: Path) -> list[str]:
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.") @click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.") @click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.") @click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
@click.option("--check-single-h1/--no-check-single-h1", "single_h1", default=True, help="Check single H1 per file.")
@click.option("--check-depth/--no-check-depth", "depth", default=True, help="Check max heading depth.")
@click.option("--check-line-length/--no-check-line-length", "line_length", default=True, help="Check line length.")
@click.option("--check-code-lang/--no-check-code-lang", "code_lang", default=True, help="Check code block languages.")
@click.option("--check-orphans/--no-check-orphans", "orphans", default=False, help="Check for orphan docs (warnings).")
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.") @click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
def main( def main(
root: str, root: str,
@@ -337,6 +472,11 @@ def main(
check_stale: bool, check_stale: bool,
check_trailing: bool, check_trailing: bool,
check_duplicates: bool, check_duplicates: bool,
single_h1: bool,
depth: bool,
line_length: bool,
code_lang: bool,
orphans: bool,
fix: bool, fix: bool,
) -> None: ) -> None:
"""Lint documentation files for structure, links, and quality.""" """Lint documentation files for structure, links, and quality."""
@@ -369,6 +509,31 @@ def main(
click.echo(_("Checking duplicate headings...")) click.echo(_("Checking duplicate headings..."))
all_issues.extend(check_duplicate_headings(root_path)) all_issues.extend(check_duplicate_headings(root_path))
# Single H1
if single_h1:
click.echo(_("Checking single H1 per file..."))
all_issues.extend(check_single_h1(root_path))
# Max heading depth
if depth:
click.echo(_("Checking max heading depth..."))
all_issues.extend(check_max_heading_depth(root_path))
# Line length (warnings — badge URLs and tables can exceed 120)
if line_length:
click.echo(_("Checking line length..."))
ll_issues = check_line_length(root_path)
for issue in ll_issues[:10]: # Show first 10 only
click.echo(f" WARN: {issue}")
if len(ll_issues) > 10:
click.echo(_(" ... and {n} more", n=len(ll_issues) - 10))
click.echo(_(" {n} long lines found (warnings only)", n=len(ll_issues)))
# Code block languages
if code_lang:
click.echo(_("Checking code block languages..."))
all_issues.extend(check_code_block_languages(root_path))
# TODO/FIXME # TODO/FIXME
if check_todo: if check_todo:
click.echo(_("Checking for TODO/FIXME markers...")) click.echo(_("Checking for TODO/FIXME markers..."))
@@ -391,15 +556,22 @@ def main(
else: else:
all_issues.extend(ws_issues) all_issues.extend(ws_issues)
# Stale docs # Stale docs (warnings)
if check_stale: if check_stale:
click.echo(_("Checking for stale docs...")) click.echo(_("Checking for stale docs..."))
stale = check_stale_docs(root_path) stale = check_stale_docs(root_path)
for issue in stale: for issue in stale:
click.echo(f" WARN: {issue}") click.echo(f" WARN: {issue}")
# Stale docs are warnings, not errors
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale))) click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
# Orphan docs (warnings)
if orphans:
click.echo(_("Checking for orphan docs..."))
orphan_issues = check_orphan_docs(root_path, docs_path)
for issue in orphan_issues:
click.echo(f" WARN: {issue}")
click.echo(_(" {n} orphan docs found (warnings only)", n=len(orphan_issues)))
# Report # Report
click.echo(f"\n{'=' * 60}") click.echo(f"\n{'=' * 60}")
if all_issues: if all_issues:
+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. for issue creation tea must be installed and configured.
Usage: 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> \ --repo <owner/repo> \
--run-id <run_id> \ --run-id <run_id> \
--workflow <workflow_name> \ --workflow <workflow_name> \
@@ -14,14 +14,13 @@ Usage:
--auto-login --auto-login
With ``--auto-login``, the script configures the tea CLI login profile 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. eliminating the need for a separate ``tea login add`` step in the workflow.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] 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.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv() 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.", 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: def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if auto_login: if auto_login:
configure_tea_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>] VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>]
""" """
import os
import re import re
import subprocess # nosec B404 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.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv() 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() commit_sha = _get_git_commit_sha()
if not commit_msg: if not commit_msg:
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)") raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) from None
task_id = extract_task_id(commit_msg) task_id = extract_task_id(commit_msg)
if not task_id: if not task_id:
+6 -5
View File
@@ -17,12 +17,11 @@ Checks performed:
8. Commit conventions conventional commit format on branch commits 8. Commit conventions conventional commit format on branch commits
Usage: 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 from __future__ import annotations
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -34,6 +33,7 @@ from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token, get_reviewer_token
load_dotenv() load_dotenv()
@@ -636,9 +636,10 @@ def main(
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES. Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
With --event: posts a manual review (skips automated checks). With --event: posts a manual review (skips automated checks).
""" """
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_reviewer_token() if (event and event.upper() == "APPROVE") else get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
owner, repo_name = repo.split("/") owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
+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`` ``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the --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 2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow. ``twine upload -u __token__ -p <token>`` flow.
3. **Skip** if neither is configured, only the Gitea release is created. 3. **Skip** if neither is configured, only the Gitea release is created.
Usage: Usage:
CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo> CI_GITEA_API_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> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
""" """
import os 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.config import GITEA_API_URL, REPO_OWNER
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv() load_dotenv()
@@ -253,9 +254,10 @@ def main(
if not tag: if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag).")) raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not gitea_token: gitea_token = get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
pypi_token = os.environ.get("PYPI_TOKEN", "") 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", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607 _run(["git", "checkout", "--orphan", "badges"]) # nosec B607
_run(["git", "rm", "-rf", "."]) # nosec B607 _run(["git", "rm", "-rf", "."]) # nosec B607
# Remove untracked files/dirs left behind (e.g. .badges/ from generate_badges) # Remove untracked files/dirs left behind, but preserve .badges/ for copy below
_run(["git", "clean", "-fdx", "-e", ".git"]) # nosec B607 _run(["git", "clean", "-fdx", "-e", ".git", "-e", badges_dir]) # nosec B607
# Copy badge files to root # Copy badge files to root
for svg in Path(badges_dir).glob("*.svg"): for svg in Path(badges_dir).glob("*.svg"):
+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. checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage: 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 python3 -m devx.ci.release --verify # Check tag/version/release alignment
""" """
+259 -319
View File
@@ -1,62 +1,75 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Sync documentation from /docs/ to the Gitea wiki via API. """Sync documentation from /docs/ to the Gitea wiki via Git.
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to Instead of using the Gitea wiki API (which is slow, unreliable, and
map file paths to wiki page titles, and creates/updates wiki pages via the prone to timeouts), this module clones the wiki Git repository,
Gitea API. Pages that exist in the wiki but not in the mapping are left copies the documentation files into it, transforms internal links
untouched (not deleted). to wiki-friendly format, commits, and pushes.
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content): This approach is:
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message} - **Faster** a single git push vs N API calls
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message} - **More reliable** no API timeouts or rate limits
- List: GET /repos/{owner}/{repo}/wiki/pages [{title, sub_url, ...}] - **Atomic** all pages sync in one commit
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, ...} - **Auto-pruning** stale wiki pages are removed automatically
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
The wiki Git URL is ``{clone_url}.wiki.git`` (Gitea convention).
Link transformations:
- ``[text](file.md)`` ``[text](file)`` (wiki pages don't use .md)
- ``[text](docs/file.md)`` ``[text](file)``
- External links (http/https/mailto) are preserved
- Anchor-only links (``#section``) are preserved
Usage: 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 from __future__ import annotations
import base64
import json import json
import logging
import os import os
import re
import subprocess # nosec B404
import tempfile
import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote, urlparse
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv() load_dotenv()
# DOCS_DIR is the repo's docs/ directory. When devx is installed as a
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
# __file__-relative path would point inside the venv, not the repo.
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
# (relative to the current working directory, which is the repo root
# in CI and local development).
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs")) DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
MAPPING_FILE = DOCS_DIR / "mapping.json" MAPPING_FILE = DOCS_DIR / "mapping.json"
# Markdown link pattern: [text](url)
_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]: def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json. """Load the file-to-wiki-page mapping from mapping.json."""
Validates that the mapping is a dict of string-to-string pairs.
"""
with open(MAPPING_FILE, encoding="utf-8") as f: with open(MAPPING_FILE, encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -69,214 +82,186 @@ def load_mapping() -> dict[str, str]:
return data return data
def read_doc_content(file_path: str) -> str: def transform_links(content: str) -> str:
"""Read markdown content from a docs file.""" """Transform markdown links from file-based to wiki-friendly format.
full_path = DOCS_DIR / file_path
with open(full_path, encoding="utf-8") as f:
return f.read()
- ``[text](file.md)`` ``[text](file)``
def encode_content(content: str) -> str: - ``[text](docs/file.md)`` ``[text](file)``
"""Encode content as base64 for the Gitea wiki API. - ``[text](../file.md)`` ``[text](file)``
- External links (http/https/mailto) preserved
The Gitea wiki API requires content_base64, not plain content. - Anchor-only links (``#section``) preserved
Sending plain content silently fails (pages are created/updated
but with empty content).
""" """
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def replace_link(match: re.Match[str]) -> str:
text = match.group(1)
url = match.group(2).strip()
# Skip external links and mailto
if url.startswith(("http://", "https://", "mailto:")):
return match.group(0)
# Skip anchor-only links
if url.startswith("#"):
return match.group(0)
# Split path and anchor
if "#" in url:
path_part, anchor = url.split("#", 1)
anchor = f"#{anchor}"
else:
path_part, anchor = url, ""
# Remove .md extension and directory prefixes
if path_part.endswith(".md"):
path_part = path_part[:-3]
# Remove directory prefix (docs/, ../, etc.)
path_part = path_part.split("/")[-1]
return f"[{text}]({path_part}{anchor})"
return _LINK_RE.sub(replace_link, content)
def decode_content(content_b64: str) -> str: def get_wiki_clone_url(owner: str, repo: str, token: str) -> str:
"""Decode base64 content from the Gitea wiki API.""" """Build the wiki Git clone URL with token auth."""
if not content_b64: # Gitea wiki repos are at {clone_url}.wiki.git
return "" # Extract base URL from API URL
return base64.b64decode(content_b64).decode("utf-8") base = GITEA_API_URL.rsplit("/api/v1", 1)[0]
# 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 list_wiki_pages(client: GiteaClient) -> dict[str, str]: def clone_wiki(wiki_url: str, dest: Path) -> bool:
"""List existing wiki pages, returning {title: sub_url}. """Clone the wiki repo into dest. Returns True if clone succeeded.
Raises :class:`APIError` if the wiki API is unavailable the caller If the wiki repo doesn't exist yet (no pages created), returns False.
is responsible for retrying or handling the failure.
""" """
pages = client._request("GET", "/wiki/pages").json() result = subprocess.run( # nosec
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages} ["git", "clone", "--depth", "1", wiki_url, str(dest)],
capture_output=True,
text=True,
def fetch_page_content(client: GiteaClient, sub_url: str) -> str: timeout=60,
"""Fetch a wiki page's content by sub_url, decoded from base64."""
try:
page = client._request("GET", f"/wiki/page/{sub_url}").json()
return decode_content(page.get("content_base64", ""))
except APIError:
return ""
def sync_page(
client: GiteaClient,
page_title: str,
content: str,
existing_pages: dict[str, str],
dry_run: bool,
) -> str:
"""Create or update a single wiki page.
Returns "created", "updated", or "skipped" (if dry-run).
If a create fails with HTTP 400 "already exists" (the page list was
stale), re-lists the wiki and falls back to an update.
"""
if dry_run:
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
return "skipped"
content_b64 = encode_content(content)
if page_title in existing_pages:
# Update existing page via PATCH
sub_url = existing_pages[page_title]
client._request(
"PATCH",
f"/wiki/page/{sub_url}",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — update {page_title}",
},
)
return "updated"
# Create new page via POST /wiki/new
try:
client._request(
"POST",
"/wiki/new",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — create {page_title}",
},
)
return "created"
except APIError as e:
if e.status == 400 and "already exists" in e.message.lower():
# The page list was stale (e.g. after a timeout-retry returned
# incomplete data). Re-list and fall back to update.
click.echo(_(" Page '{title}' already exists (stale list). Re-listing and updating...", title=page_title))
fresh_pages = _list_wiki_pages_with_retry(client)
if page_title in fresh_pages:
sub_url = fresh_pages[page_title]
client._request(
"PATCH",
f"/wiki/page/{sub_url}",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — update {page_title} (create→update fallback)",
},
)
return "updated"
raise
def verify_wiki_page(
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
) -> bool:
"""Verify that a wiki page has non-empty content matching the docs.
Returns True if the page content matches, False otherwise.
"""
if page_title not in existing_pages:
return False
sub_url = existing_pages[page_title]
actual = fetch_page_content(client, sub_url)
return actual.strip() == expected_content.strip()
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
"""List wiki pages with tenacity retry on APIError.
The Gitea wiki API can be slow (it renders pages on each request)
and may time out. Uses 5 attempts with exponential backoff to handle
transient slowness.
"""
_logger = logging.getLogger("sync_wiki")
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=2, min=2, max=16),
retry=retry_if_exception_type(APIError),
before_sleep=before_sleep_log(_logger, logging.WARNING),
reraise=True,
) )
def _do_list() -> dict[str, str]: return result.returncode == 0
return list_wiki_pages(client)
return _do_list()
def verify_wiki_integrity( def init_wiki(dest: Path) -> None:
client: GiteaClient, """Initialize a fresh wiki repo (when clone fails)."""
dest.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "init"], cwd=dest, capture_output=True, check=True) # nosec
subprocess.run( # nosec
["git", "config", "user.email", "ci@oblachno.fyi"],
cwd=dest,
capture_output=True,
check=True,
)
subprocess.run( # nosec
["git", "config", "user.name", "CI Wiki Sync"],
cwd=dest,
capture_output=True,
check=True,
)
def sync_files(
docs_dir: Path,
wiki_dir: Path,
mapping: dict[str, str], mapping: dict[str, str],
synced: dict[str, str], dry_run: bool,
) -> list[str]: ) -> tuple[int, int]:
"""Comprehensive wiki verification. """Copy docs files to wiki dir with link transformation.
Checks: Returns (synced, pruned) counts.
1. Every mapped page exists in the wiki
2. Every mapped page has non-empty content
3. Every mapped page's content matches the docs
4. No stale pages exist in the wiki (pages not in mapping)
5. Page count matches
Returns a list of failure messages (empty if all checks pass).
If the wiki API is temporarily unavailable (all retry attempts
fail), returns an empty list with a warning the sync itself
already succeeded, so a transient API outage should not fail the job.
""" """
failures: list[str] = [] synced = 0
try: # Build set of expected wiki filenames
existing_pages = _list_wiki_pages_with_retry(client) expected_files: set[str] = set()
except APIError:
click.echo(
_(
"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.",
count=len(synced),
)
)
return []
expected_titles = set(mapping.values()) for file_path, page_title in sorted(mapping.items()):
src = docs_dir / file_path
if not src.exists():
click.echo(_(" WARN: Mapped file {file} not found, skipping", file=file_path))
continue
# Check 1: Page count content = src.read_text(encoding="utf-8")
if len(existing_pages) != len(expected_titles): if not content.strip():
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}") click.echo(_(" WARN: Mapped file {file} is empty, skipping", file=file_path))
continue
# Check 2: Missing pages (in mapping but not in wiki) # Transform links
missing = expected_titles - set(existing_pages.keys()) transformed = transform_links(content)
for title in sorted(missing):
failures.append(f"Missing page: {title}")
# Check 3: Stale pages (in wiki but not in mapping) # Wiki filename: Gitea uses a dash-marker convention for titles with dashes
stale = set(existing_pages.keys()) - expected_titles fname = wiki_filename(page_title)
for title in sorted(stale): expected_files.add(fname)
failures.append(f"Stale page (not in mapping): {title}")
# Check 4: Content verification if not dry_run:
for page_title, expected_content in sorted(synced.items()): dest = wiki_dir / fname
ok = verify_wiki_page(client, page_title, expected_content, existing_pages) dest.write_text(transformed, encoding="utf-8")
if not ok: synced += 1
sub_url = existing_pages.get(page_title, "?") click.echo(_(" Synced: {title}{file}", title=page_title, file=fname))
actual = fetch_page_content(client, sub_url)
if not actual.strip():
failures.append(f"Empty content: {page_title}")
else:
failures.append(f"Content mismatch: {page_title}")
return failures # Prune stale pages (in wiki but not in mapping)
pruned = 0
if not dry_run:
for existing in wiki_dir.glob("*.md"):
if existing.name not in expected_files:
existing.unlink()
pruned += 1
click.echo(_(" Pruned: {file} (not in mapping)", file=existing.name))
return synced, pruned
def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
"""Commit changes and push to the wiki repo. Returns True if pushed."""
if dry_run:
click.echo(_("[dry-run] Would commit and push wiki changes"))
return False
# Stage all changes
subprocess.run(["git", "add", "-A"], cwd=wiki_dir, capture_output=True, check=True) # nosec
# Check if there are changes to commit
result = subprocess.run( # nosec
["git", "diff", "--cached", "--quiet"],
cwd=wiki_dir,
capture_output=True,
)
if result.returncode == 0:
click.echo(_("No changes to sync — wiki is up to date."))
return False
# 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,
capture_output=True,
check=True,
)
# Push
result = subprocess.run( # nosec
["git", "push", "--force", wiki_url, "HEAD:master"],
cwd=wiki_dir,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
click.echo(_("Push failed: {error}", error=result.stderr))
return False
return True
@click.command() @click.command()
@@ -286,18 +271,14 @@ def verify_wiki_integrity(
"--verify", "--verify",
is_flag=True, is_flag=True,
default=False, default=False,
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.", help="After syncing, verify each page exists in the wiki. Exit 1 if any page is missing.",
) )
@click.option( def main(dry_run: bool, repo: str | None, verify: bool) -> None:
"--strict", """Sync documentation to the Gitea wiki via Git."""
is_flag=True, try:
default=False, token = get_ci_token()
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.", except click.ClickException:
) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
if repo is None: if repo is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
@@ -309,107 +290,66 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE)) raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
mapping = load_mapping() mapping = load_mapping()
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) wiki_url = get_wiki_clone_url(owner, repo_name, token)
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping))) click.echo(_("Syncing {count} documentation pages to wiki via Git...", count=len(mapping)))
try: with tempfile.TemporaryDirectory() as tmpdir:
existing_pages = _list_wiki_pages_with_retry(client) wiki_dir = Path(tmpdir) / "wiki"
except APIError as e:
raise click.ClickException( click.echo(_("Cloning wiki repo..."))
if clone_wiki(wiki_url, wiki_dir):
click.echo(_("Cloned existing wiki."))
else:
click.echo(_("Wiki repo not found or empty — initializing fresh."))
init_wiki(wiki_dir)
click.echo(_("Syncing files..."))
synced, pruned = sync_files(DOCS_DIR, wiki_dir, mapping, dry_run)
click.echo(
_( _(
"Failed to list existing wiki pages after retries: {error}. " "\nDone! Synced: {synced}, Pruned: {pruned}",
"Aborting to avoid creating duplicate pages.", synced=synced,
error=e, pruned=pruned,
) )
) from e
if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
created = 0
updated = 0
skipped = 0
synced: dict[str, str] = {} # title -> content, for verification
for file_path, page_title in sorted(mapping.items()):
try:
content = read_doc_content(file_path)
except FileNotFoundError:
raise click.ClickException(
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
) from None
if not content.strip():
raise click.ClickException(
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
) from None
result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created":
created += 1
click.echo(_(" Created: {title}", title=page_title))
elif result == "updated":
updated += 1
click.echo(_(" Updated: {title}", title=page_title))
else:
skipped += 1
synced[page_title] = content
click.echo(
_(
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
created=created,
updated=updated,
skipped=skipped,
) )
)
# --strict implies --verify if dry_run:
do_verify = verify or strict click.echo(_("[dry-run] No changes pushed."))
return
if do_verify and not dry_run: click.echo(_("Committing and pushing..."))
if strict: pushed = commit_and_push(wiki_dir, wiki_url, dry_run)
click.echo(_("\nRunning full wiki integrity check...")) if pushed:
failures = verify_wiki_integrity(client, mapping, synced) click.echo(_("Wiki synced successfully."))
if failures: elif not dry_run:
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures))) click.echo(_("No push needed (no changes or push failed)."))
for f in failures:
click.echo(f" - {f}") # Verification
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures))) if verify and not dry_run:
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced))) if pushed:
else: click.echo(_("Waiting 5s for Gitea to process pushed commits..."))
click.echo(_("\nVerifying wiki pages have content...")) time.sleep(5)
# Re-fetch the page list to get updated sub_urls click.echo(_("\nVerifying wiki pages..."))
try: # Re-clone to verify
existing_pages = _list_wiki_pages_with_retry(client) verify_dir = Path(tmpdir) / "verify"
except APIError: if not clone_wiki(wiki_url, verify_dir):
click.echo( click.echo(_("FAIL: Could not clone wiki for verification."))
_( raise click.ClickException(_("Wiki verification failed — could not clone wiki"))
"WARNING: Could not re-fetch wiki page list for verification. "
"Skipping content verification due to transient API issue."
)
)
return
failures = 0 failures = 0
for page_title, expected_content in sorted(synced.items()): for _file_path, page_title in sorted(mapping.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages) fname = wiki_filename(page_title)
if ok: if (verify_dir / fname).exists():
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content))) click.echo(_(" OK: {title}", title=page_title))
else: else:
click.echo(_(" FAIL: {title}content mismatch or empty!", title=page_title)) click.echo(_(" FAIL: {title}page not found in wiki!", title=page_title))
failures += 1 failures += 1
if failures > 0: if failures > 0:
click.echo(
_(
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
failures=failures,
)
)
raise click.ClickException( raise click.ClickException(
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures) _("Wiki verification failed — {failures} page(s) missing", failures=failures)
) )
click.echo(_("\nVerification passed — all wiki pages have correct content.")) click.echo(_("\nVerification passed — all wiki pages exist."))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+6 -5
View File
@@ -40,7 +40,6 @@ Usage::
from __future__ import annotations from __future__ import annotations
import json import json
import os
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
from typing import Any from typing import Any
@@ -49,6 +48,7 @@ import click
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
class TeaCLIError(Exception): class TeaCLIError(Exception):
@@ -56,10 +56,10 @@ class TeaCLIError(Exception):
def configure_tea_login(login_name: str = "devx") -> None: 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. 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 Used by CI scripts (publish, notify_failure) that need tea login but
run in containerized environments where ``make setup`` was not called. 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.")) click.echo(_("tea not installed — skipping login configuration."))
return return
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_ci_token()
except click.ClickException:
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration.")) click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
return return
+56 -12
View File
@@ -39,6 +39,9 @@
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss) # DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml) # DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows) # 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_PYTHON ?= python3
DEVX_PR_BASE ?= master DEVX_PR_BASE ?= master
@@ -52,15 +55,18 @@ DEVX_GITEA_PYPI_ORG ?= oblachno-oss
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
DEVX_WORKFLOW_DIR ?= .gitea/workflows DEVX_WORKFLOW_DIR ?= .gitea/workflows
DEVX_DOCKERFILE_PATHS ?= docker DEVX_DOCKERFILE_PATHS ?= docker
DEVX_VALE_LEVEL ?= warning
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured. # PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]' # 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. # 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) # Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ _TOKEN="$$CI_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \ _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 $(DEVX_BIN)/pip
# ── Virtual environment management ──────────────────────────────────────────── # ── Virtual environment management ────────────────────────────────────────────
@@ -192,12 +198,14 @@ devx-pr-rebase:
# ── Environment setup ───────────────────────────────────────────────────────── # ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other # 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: devx-configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ @if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ _TOKEN="$$CI_GITEA_API_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; \ [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)." [ -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 # Create .env from .env.example if it doesn't exist
devx-env: 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. # Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release # 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-notify-failure:
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \ @. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
export PATH="$(HOME)/.local/bin:$$PATH"; \ export PATH="$(HOME)/.local/bin:$$PATH"; \
@@ -304,7 +312,7 @@ devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov @$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
devx-pytest-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 ──────────────────────────────────────────────────────────── # ── Quality checks ────────────────────────────────────────────────────────────
@@ -328,10 +336,46 @@ devx-check-docs:
devx-check-doc-versions: devx-check-doc-versions:
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . @$(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: devx-vale:
@export PATH="$$HOME/.local/bin:$$PATH" && \ @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 # Verify test suite timing
devx-check-test-speed: devx-check-test-speed:
+6 -2
View File
@@ -30,6 +30,7 @@ import click
import requests import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -86,7 +87,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
return total 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. """Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default. Tries the Gitea API first, then falls back to env vars, then default.
@@ -142,7 +143,10 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+6 -2
View File
@@ -22,7 +22,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. 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). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -45,6 +45,7 @@ import requests
from devx.config import REPO_NAME, REPO_OWNER from devx.config import REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
POLL_INTERVAL = 10 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: def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") 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")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests") job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) 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 import click
from devx.tokens import get_developer_token
def arch_string() -> str: def arch_string() -> str:
"""Return the architecture string used by release assets. """Return the architecture string used by release assets.
@@ -45,8 +47,9 @@ def detect_pr_number() -> int | None:
if branch == "HEAD": if branch == "HEAD":
return None return None
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
except click.ClickException:
return None return None
owner = os.environ.get("DEVX_REPO_OWNER", "") 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) - ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``) - ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME`` Registry authentication uses ``CI_GITEA_API_TOKEN`` (or legacy ``CI_GITEA_TOKEN``)
environment variables, matching the existing CI workflow patterns. and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workflow patterns.
""" """
from __future__ import annotations from __future__ import annotations
@@ -49,6 +49,7 @@ from pathlib import Path
import click import click
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
@dataclass @dataclass
@@ -221,9 +222,12 @@ def push_image(
def _get_registry_creds() -> tuple[str, str]: def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables.""" """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", "") username = os.environ.get("CI_GITEA_USERNAME", "")
return username, token return username, token or ""
@click.command() @click.command()
+6 -5
View File
@@ -28,12 +28,11 @@ Usage::
--keep 2 \\ --keep 2 \\
--dry-run --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 from __future__ import annotations
import os
import time import time
from typing import Any from typing import Any
@@ -42,6 +41,7 @@ import requests
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
def list_package_versions( def list_package_versions(
@@ -187,9 +187,10 @@ def main(
api_url: str | None, api_url: str | None,
) -> None: ) -> None:
"""Clean up old Docker image versions from a Gitea registry.""" """Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) from None
if not owner: if not owner:
owner = REPO_OWNER owner = REPO_OWNER
if not owner: if not owner:
+7 -3
View File
@@ -7,8 +7,8 @@ ci-improvement, doc-improvement, workflow-improvement) are created
idempotently via ``ensure_label``. idempotently via ``ensure_label``.
Usage: Usage:
CI_GITEA_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
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 --owner my-org
""" """
from __future__ import annotations 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.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
def _default_status_checks() -> list[str]: 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: 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.""" """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: if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME 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, VIKUNJA_PROJECT_ID,
) )
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token, get_vikunja_token
load_dotenv() 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. Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) except click.ClickException:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) from None
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
if not task: if not task:
@@ -118,9 +120,10 @@ def create_pr(
), ),
) )
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) 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) vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}" 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 from __future__ import annotations
import os
import click import click
from dotenv import load_dotenv from dotenv import load_dotenv
from devx.api_clients import VikunjaClient from devx.api_clients import VikunjaClient
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv() 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).") @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: def cli(title: str, description: str, project_id: int | None) -> None:
"""Create a Vikunja task and print its identifier.""" """Create a Vikunja task and print its identifier."""
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) 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 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 Looks for the first subdirectory under ``src/`` that contains
an ``__init__.py`` file with ``__version__``. an ``__init__.py`` file with ``__version__``.
Returns the package directory name (e.g., ``devx``) or ``None`` if Returns the package directory name (e.g., ``devx``,
no package is found. ``grm``) or ``None`` if no package is found.
""" """
src_dir = repo_root / "src" src_dir = repo_root / "src"
if not src_dir.is_dir(): 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 from __future__ import annotations
import os
import click import click
from dotenv import load_dotenv from dotenv import load_dotenv
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr from devx.tools.pr_status import _get_current_branch_pr
@@ -48,9 +47,10 @@ def cli(
repo: str | None, repo: str | None,
) -> None: ) -> None:
"""Add one or more labels to a pull request (idempotent).""" """Add one or more labels to a pull request (idempotent)."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER repo_owner = owner or REPO_OWNER
if not 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 from __future__ import annotations
import os
import click import click
from dotenv import load_dotenv from dotenv import load_dotenv
from devx.api_clients import APIError, GiteaClient from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr from devx.tools.pr_status import _get_current_branch_pr
@@ -126,9 +125,10 @@ def cli(
repo: str | None, repo: str | None,
) -> None: ) -> None:
"""Fetch logs for failed CI jobs on a pull request.""" """Fetch logs for failed CI jobs on a pull request."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER repo_owner = owner or REPO_OWNER
if not 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.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools._shared import detect_pr_number 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.""" """Rebase a pull request's head branch onto master via Gitea API."""
load_dotenv() load_dotenv()
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) 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() pr_num = pr or detect_pr_number()
if not pr_num: 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 from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import time import time
@@ -34,6 +33,7 @@ from dotenv import load_dotenv
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name from devx.tools.create_pr import get_repo_name
load_dotenv() load_dotenv()
@@ -139,9 +139,10 @@ def cli(
repo: str | None, repo: str | None,
) -> None: ) -> None:
"""Check CI status for a pull request or commit.""" """Check CI status for a pull request or commit."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER repo_owner = owner or REPO_OWNER
if not repo_owner: if not repo_owner:
+7 -5
View File
@@ -20,7 +20,6 @@ Exit codes:
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import click import click
@@ -29,6 +28,7 @@ from dotenv import load_dotenv
from devx.api_clients import VikunjaClient 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.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv() 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). Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
except click.ClickException:
return False return False
client = VikunjaClient(VIKUNJA_API_URL, token) 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 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", "") try:
if not token: get_vikunja_token()
except click.ClickException:
click.echo( click.echo(
_( _(
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. " "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
+8 -5
View File
@@ -16,6 +16,8 @@ from pathlib import Path
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.tokens import get_developer_token
load_dotenv() load_dotenv()
@@ -64,19 +66,20 @@ def _install_ansible_collections(bin_dir: str) -> None:
def _configure_tea_login() -> 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. 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") tea_bin = shutil.which("tea")
if tea_bin is None: if tea_bin is None:
click.echo("tea: not installed — run 'make install-tools' to install it.") click.echo("tea: not installed — run 'make install-tools' to install it.")
return return
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.") except click.ClickException:
click.echo("tea: Gitea API token not set — skipping login configuration.")
return return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") 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 import click
from devx.tokens import get_developer_token
DEFAULT_VENV = ".venv" DEFAULT_VENV = ".venv"
OPT_VENV = "/opt/venv" OPT_VENV = "/opt/venv"
FALLBACK_TARGET = "setup-ci" FALLBACK_TARGET = "setup-ci"
@@ -67,7 +69,10 @@ def _install_in_image(
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec] cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
env = os.environ.copy() env = os.environ.copy()
token = env.get("CI_GITEA_TOKEN", "") try:
token = get_developer_token()
except click.ClickException:
token = None
if token: if token:
username = env.get("CI_GITEA_USERNAME", "emil") username = env.get("CI_GITEA_USERNAME", "emil")
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url( env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
+824 -616
View File
@@ -47,13 +47,13 @@
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)", "ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)" "zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
}, },
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": { "\nDone! Synced: {synced}, Pruned: {pruned}": {
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "bg": "",
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "de": "",
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "en": "\nDone! Synced: {synced}, Pruned: {pruned}",
"pl": "\nGotowe! Utworzono: {created}, Zaktualizowano: {updated}, Pominięto: {skipped}", "pl": "",
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "ru": "",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" "zh": ""
}, },
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": { "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
"bg": "\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.", "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." "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.": { "\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.", "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.", "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.", "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." "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:": { "\nGenerated {count} badges:": {
"bg": "\nGenerated {count} badges:", "bg": "\nGenerated {count} badges:",
"de": "\nGenerated {count} badges:", "de": "\nGenerated {count} badges:",
@@ -87,22 +103,6 @@
"ru": "\nGenerated {count} badges:", "ru": "\nGenerated {count} badges:",
"zh": "\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}": { "\nKeeping {kept}, would delete {count}": {
"bg": "\nKeeping {kept}, would delete {count}", "bg": "\nKeeping {kept}, would delete {count}",
"de": "\nKeeping {kept}, would delete {count}", "de": "\nKeeping {kept}, would delete {count}",
@@ -127,6 +127,22 @@
"ru": "\nMissing documentation:", "ru": "\nMissing documentation:",
"zh": "\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}": { "\nResult: {status}": {
"bg": "\nResult: {status}", "bg": "\nResult: {status}",
"de": "\nResult: {status}", "de": "\nResult: {status}",
@@ -151,13 +167,13 @@
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.", "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\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...": { "\nRun with --fix to auto-update version references.": {
"bg": "\nRunning full wiki integrity check...", "bg": "",
"de": "\nRunning full wiki integrity check...", "de": "",
"en": "\nRunning full wiki integrity check...", "en": "\nRun with --fix to auto-update version references.",
"pl": "\nUruchamianie pełnej kontroli integralności wiki...", "pl": "",
"ru": "\nRunning full wiki integrity check...", "ru": "",
"zh": "\nRunning full wiki integrity check..." "zh": ""
}, },
"\nTag → Commit alignment:": { "\nTag → Commit alignment:": {
"bg": "\nTag → Commit alignment:", "bg": "\nTag → Commit alignment:",
@@ -183,29 +199,21 @@
"ru": "\nUser-facing changes ({count}):", "ru": "\nUser-facing changes ({count}):",
"zh": "\nUser-facing changes ({count}):" "zh": "\nUser-facing changes ({count}):"
}, },
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": { "\nVerification passed — all wiki pages exist.": {
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "bg": "",
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "de": "",
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "en": "\nVerification passed — all wiki pages exist.",
"pl": "\nWeryfikacja NIEUDANA: {failures} strona(y) ma pustą lub niezgodną treść!", "pl": "",
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "ru": "",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!" "zh": ""
}, },
"\nVerification passed — all wiki pages have correct content.": { "\nVerifying wiki pages...": {
"bg": "\nVerification passed — all wiki pages have correct content.", "bg": "",
"de": "\nVerification passed — all wiki pages have correct content.", "de": "",
"en": "\nVerification passed — all wiki pages have correct content.", "en": "\nVerifying wiki pages...",
"pl": "\nWeryfikacja zakończona pomyślnie — wszystkie strony wiki mają poprawną treść.", "pl": "",
"ru": "\nVerification passed — all wiki pages have correct content.", "ru": "",
"zh": "\nVerification passed — all wiki pages have correct content." "zh": ""
},
"\nVerifying wiki pages have content...": {
"bg": "\nVerifying wiki pages have content...",
"de": "\nVerifying wiki pages have content...",
"en": "\nVerifying wiki pages have content...",
"pl": "\nWeryfikowanie, czy strony wiki mają treść...",
"ru": "\nVerifying wiki pages have content...",
"zh": "\nVerifying wiki pages have content..."
}, },
"\nWorkflow-only changes ({count}):": { "\nWorkflow-only changes ({count}):": {
"bg": "\nWorkflow-only changes ({count}):", "bg": "\nWorkflow-only changes ({count}):",
@@ -351,6 +359,30 @@
"ru": " - Требуемые проверки статуса: {checks}", "ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {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": { " Auto-fixed trailing whitespace in {n} files": {
"bg": " Auto-fixed trailing whitespace in {n} files", "bg": " Auto-fixed trailing whitespace in {n} files",
"de": " Auto-fixed trailing whitespace in {n} files", "de": " Auto-fixed trailing whitespace in {n} files",
@@ -391,14 +423,6 @@
"ru": " Collecting version...", "ru": " Collecting version...",
"zh": " 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}": { " Deleted: {version}": {
"bg": " Deleted: {version}", "bg": " Deleted: {version}",
"de": " Deleted: {version}", "de": " Deleted: {version}",
@@ -407,13 +431,13 @@
"ru": " Deleted: {version}", "ru": " Deleted: {version}",
"zh": " Deleted: {version}" "zh": " Deleted: {version}"
}, },
" FAIL: {title} — content mismatch or empty!": { " FAIL: {title} — page not found in wiki!": {
"bg": " FAIL: {title} — content mismatch or empty!", "bg": "",
"de": " FAIL: {title} — content mismatch or empty!", "de": "",
"en": " FAIL: {title} — content mismatch or empty!", "en": " FAIL: {title} — page not found in wiki!",
"pl": " BŁĄD: {title} — treść niezgodna lub pusta!", "pl": "",
"ru": " FAIL: {title} — content mismatch or empty!", "ru": "",
"zh": " FAIL: {title} — content mismatch or empty!" "zh": ""
}, },
" FAILED to delete: {version}": { " FAILED to delete: {version}": {
"bg": " FAILED to delete: {version}", "bg": " FAILED to delete: {version}",
@@ -423,6 +447,14 @@
"ru": " FAILED to delete: {version}", "ru": " FAILED to delete: {version}",
"zh": " 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}": { " Generated: {path}": {
"bg": " Generated: {path}", "bg": " Generated: {path}",
"de": " Generated: {path}", "de": " Generated: {path}",
@@ -479,13 +511,13 @@
"ru": " OK: {script}", "ru": " OK: {script}",
"zh": " OK: {script}" "zh": " OK: {script}"
}, },
" OK: {title} ({chars} chars)": { " OK: {title}": {
"bg": " OK: {title} ({chars} chars)", "bg": "",
"de": " OK: {title} ({chars} chars)", "de": "",
"en": " OK: {title} ({chars} chars)", "en": " OK: {title}",
"pl": " OK: {title} ({chars} znaków)", "pl": "",
"ru": " OK: {title} ({chars} chars)", "ru": "",
"zh": " OK: {title} ({chars} chars)" "zh": ""
}, },
" Package: {pkg}": { " Package: {pkg}": {
"bg": " Package: {pkg}", "bg": " Package: {pkg}",
@@ -495,6 +527,14 @@
"ru": " Package: {pkg}", "ru": " Package: {pkg}",
"zh": " Package: {pkg}" "zh": " Package: {pkg}"
}, },
" Pruned: {file} (not in mapping)": {
"bg": "",
"de": "",
"en": " Pruned: {file} (not in mapping)",
"pl": "",
"ru": "",
"zh": ""
},
" Quality checks: {checks}": { " Quality checks: {checks}": {
"bg": " Quality checks: {checks}", "bg": " Quality checks: {checks}",
"de": " Quality checks: {checks}", "de": " Quality checks: {checks}",
@@ -511,6 +551,22 @@
"ru": " Repo root: {root}", "ru": " Repo root: {root}",
"zh": " 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}": { " Test paths: {testpaths}": {
"bg": " Test paths: {testpaths}", "bg": " Test paths: {testpaths}",
"de": " Test paths: {testpaths}", "de": " Test paths: {testpaths}",
@@ -519,13 +575,21 @@
"ru": " Test paths: {testpaths}", "ru": " Test paths: {testpaths}",
"zh": " Test paths: {testpaths}" "zh": " Test paths: {testpaths}"
}, },
" Updated: {title}": { " WARN: Mapped file {file} is empty, skipping": {
"bg": " Updated: {title}", "bg": "",
"de": " Updated: {title}", "de": "",
"en": " Updated: {title}", "en": " WARN: Mapped file {file} is empty, skipping",
"pl": " Zaktualizowano: {title}", "pl": "",
"ru": " Updated: {title}", "ru": "",
"zh": " Updated: {title}" "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})": { " WARNING: Could not extract coverage from pytest output (rc={rc})": {
"bg": " 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})", "ru": " {name}: {label}={message} ({color})",
"zh": " {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)": { " {n} stale docs found (warnings only)": {
"bg": " {n} stale docs found (warnings only)", "bg": " {n} stale docs found (warnings only)",
"de": " {n} stale docs found (warnings only)", "de": " {n} stale docs found (warnings only)",
@@ -623,6 +703,14 @@
"ru": " {n} stale docs found (warnings only)", "ru": " {n} stale docs found (warnings only)",
"zh": " {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})": { " {version} (created: {created})": {
"bg": " {version} (created: {created})", "bg": " {version} (created: {created})",
"de": " {version} (created: {created})", "de": " {version} (created: {created})",
@@ -727,6 +815,22 @@
"ru": "Assigned {count} items to runner {runner_index}: {encoded}", "ru": "Assigned {count} items to runner {runner_index}: {encoded}",
"zh": "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}": { "Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
"bg": "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}", "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 \"Заголовок задачи\"", "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 \"任务标题\"" "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": { "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", "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", "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", "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" "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)": { "Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "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)", "de": "Branch name (e.g., DEVX-256-fix-foo)",
@@ -831,6 +959,14 @@
"ru": "CI checks failed.", "ru": "CI checks failed.",
"zh": "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": { "CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required", "bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required", "de": "CI_GITEA_TOKEN environment variable required",
@@ -847,6 +983,14 @@
"ru": "CI_GITEA_TOKEN is not set.", "ru": "CI_GITEA_TOKEN is not set.",
"zh": "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.": { "CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.", "bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines 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.", "ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "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...": { "Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...", "bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...", "de": "Checking CLI command documentation...",
@@ -871,6 +1031,14 @@
"ru": "Checking CLI command documentation...", "ru": "Checking CLI command documentation...",
"zh": "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...": { "Checking docs structure...": {
"bg": "Checking docs structure...", "bg": "Checking docs structure...",
"de": "Checking docs structure...", "de": "Checking docs structure...",
@@ -895,6 +1063,14 @@
"ru": "Checking for TODO/FIXME markers...", "ru": "Checking for TODO/FIXME markers...",
"zh": "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...": { "Checking for stale docs...": {
"bg": "Checking for stale docs...", "bg": "Checking for stale docs...",
"de": "Checking for stale docs...", "de": "Checking for stale docs...",
@@ -919,6 +1095,22 @@
"ru": "Checking internal links...", "ru": "Checking internal links...",
"zh": "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...": { "Checking required files...": {
"bg": "Checking required files...", "bg": "Checking required files...",
"de": "Checking required files...", "de": "Checking required files...",
@@ -927,6 +1119,14 @@
"ru": "Checking required files...", "ru": "Checking required files...",
"zh": "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}...": { "Checking status for PR #{pr_number}...": {
"bg": "Checking status for PR #{pr_number}...", "bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...", "de": "Checking status for PR #{pr_number}...",
@@ -943,6 +1143,30 @@
"ru": "Checking trailing whitespace...", "ru": "Checking trailing whitespace...",
"zh": "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}": { "Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}", "bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}", "de": "Command failed ({cmd}): {stderr}",
@@ -967,6 +1191,14 @@
"ru": "Commit: {sha}", "ru": "Commit: {sha}",
"zh": "Commit: {sha}" "zh": "Commit: {sha}"
}, },
"Committing and pushing...": {
"bg": "",
"de": "",
"en": "Committing and pushing...",
"pl": "",
"ru": "",
"zh": ""
},
"Comparing {base}..{head} ({count} files changed)": { "Comparing {base}..{head} ({count} files changed)": {
"bg": "Comparing {base}..{head} ({count} files changed)", "bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)", "de": "Comparing {base}..{head} ({count} files changed)",
@@ -1015,6 +1247,14 @@
"ru": "Configuring tea login '{name}' for {url}...", "ru": "Configuring tea login '{name}' for {url}...",
"zh": "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}": { "Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}", "bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}", "de": "Aktueller Branch konnte nicht erkannt werden: {error}",
@@ -1031,6 +1271,14 @@
"ru": "Could not determine head SHA for PR #{pr_number}.", "ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "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.": { "Could not extract conventional commit message from PR commits.": {
"bg": "Could not extract conventional commit message from PR commits.", "bg": "Could not extract conventional commit message from PR commits.",
"de": "Could not extract conventional commit message from PR commits.", "de": "Could not extract conventional commit message from PR commits.",
@@ -1119,6 +1367,14 @@
"ru": "Dependencies must have documentation comments.", "ru": "Dependencies must have documentation comments.",
"zh": "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": { "Docker daemon already running": {
"bg": "Докер демонът вече работи", "bg": "Докер демонът вече работи",
"de": "Docker-Daemon läuft bereits", "de": "Docker-Daemon läuft bereits",
@@ -1207,6 +1463,22 @@
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}", "ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 '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:": { "FAIL: {n} documentation issues found:": {
"bg": "FAIL: {n} documentation issues found:", "bg": "FAIL: {n} documentation issues found:",
"de": "FAIL: {n} documentation issues found:", "de": "FAIL: {n} documentation issues found:",
@@ -1263,6 +1535,30 @@
"ru": "Failed to list versions for {name}: {error}", "ru": "Failed to list versions for {name}: {error}",
"zh": "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}...": { "Fetching logs for PR #{pr_number}...": {
"bg": "Fetching logs for PR #{pr_number}...", "bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...", "de": "Fetching logs for PR #{pr_number}...",
@@ -1271,13 +1567,29 @@
"ru": "Fetching logs for PR #{pr_number}...", "ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..." "zh": "Fetching logs for PR #{pr_number}..."
}, },
"Found {count} existing wiki pages.": { "Fetching origin/master...": {
"bg": "Found {count} existing wiki pages.", "bg": "Fetching origin/master...",
"de": "Found {count} existing wiki pages.", "de": "Fetching origin/master...",
"en": "Found {count} existing wiki pages.", "en": "Fetching origin/master...",
"pl": "Znaleziono {count} istniejących stron wiki.", "pl": "Fetching origin/master...",
"ru": "Found {count} existing wiki pages.", "ru": "Fetching origin/master...",
"zh": "Found {count} existing wiki pages." "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.": { "Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "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)", "ru": "Found {count} stale documentation reference(s)",
"zh": "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):": { "Found {count} version(s):": {
"bg": "Found {count} version(s):", "bg": "Found {count} version(s):",
"de": "Found {count} version(s):", "de": "Found {count} version(s):",
@@ -1519,6 +1839,14 @@
"ru": "Linting documentation in {root}...", "ru": "Linting documentation in {root}...",
"zh": "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}": { "Manifest file not found: {path}": {
"bg": "Manifest file not found: {path}", "bg": "Manifest file not found: {path}",
"de": "Manifest file not found: {path}", "de": "Manifest file not found: {path}",
@@ -1535,22 +1863,6 @@
"ru": "Manifest must be a JSON list", "ru": "Manifest must be a JSON list",
"zh": "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.": { "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.", "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.", "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}.", "ru": "No CI checks found for commit {sha}.",
"zh": "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": { "No badge SVG files generated": {
"bg": "No badge SVG files generated", "bg": "No badge SVG files generated",
"de": "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", "ru": "No badge URLs found to update — README already up to date",
"zh": "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}.": { "No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.", "bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.", "de": "No changes between {base} and {head}.",
@@ -1655,6 +1983,14 @@
"ru": "No changes between {base} and {head}.", "ru": "No changes between {base} and {head}.",
"zh": "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.": { "No failed jobs.": {
"bg": "No failed jobs.", "bg": "No failed jobs.",
"de": "No failed jobs.", "de": "No failed jobs.",
@@ -1687,6 +2023,14 @@
"ru": "No open PR found for branch '{branch}'.", "ru": "No open PR found for branch '{branch}'.",
"zh": "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.": { "No staged changes — version and changelog already up to date.": {
"bg": "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.", "de": "No staged changes — version and changelog already up to date.",
@@ -1767,6 +2111,14 @@
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.", "ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead." "zh": "Note: Self-approval not allowed. Posting COMMENT instead."
}, },
"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)": { "Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)", "bg": "Only check staged files (for pre-commit)",
"de": "Only check staged files (for pre-commit)", "de": "Only check staged files (for pre-commit)",
@@ -1871,6 +2223,14 @@
"ru": "PASSED: {pair}", "ru": "PASSED: {pair}",
"zh": "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}": { "PR already exists: #{index} — {url}": {
"bg": "PR вече съществува: #{index} — {url}", "bg": "PR вече съществува: #{index} — {url}",
"de": "PR existiert bereits: #{index} — {url}", "de": "PR existiert bereits: #{index} — {url}",
@@ -2047,6 +2407,14 @@
"ru": "Publishing release {tag}...", "ru": "Publishing release {tag}...",
"zh": "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}": { "Push failed for {tag}: {error}": {
"bg": "Push failed for {tag}: {error}", "bg": "Push failed for {tag}: {error}",
"de": "Push failed for {tag}: {error}", "de": "Push failed for {tag}: {error}",
@@ -2055,6 +2423,14 @@
"ru": "Push failed for {tag}: {error}", "ru": "Push failed for {tag}: {error}",
"zh": "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}": { "Pushed README update with badge SHA {sha}": {
"bg": "Pushed README update with badge SHA {sha}", "bg": "Pushed README update with badge SHA {sha}",
"de": "Pushed README update with badge SHA {sha}", "de": "Pushed README update with badge SHA {sha}",
@@ -2071,6 +2447,14 @@
"ru": "Pushed release commit to master.", "ru": "Pushed release commit to master.",
"zh": "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}": { "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}", "bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}", "de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
@@ -2087,6 +2471,46 @@
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).", "ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "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": { "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", "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", "de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
@@ -2127,14 +2551,6 @@
"ru": "Release commit — skipping all post-merge jobs.", "ru": "Release commit — skipping all post-merge jobs.",
"zh": "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}": { "Release creation failed: {error}": {
"bg": "Release creation failed: {error}", "bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}", "de": "Release creation failed: {error}",
@@ -2191,6 +2607,14 @@
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.", "ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" "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.": { "Review body must be at least 50 characters.": {
"bg": "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.", "de": "Review body must be at least 50 characters.",
@@ -2279,6 +2703,30 @@
"ru": "Running: {scenario} on {platform}", "ru": "Running: {scenario} on {platform}",
"zh": "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": { "Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check", "bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check", "de": "Skip Vikunja title match check",
@@ -2319,13 +2767,21 @@
"ru": "Synced to latest origin/{branch}", "ru": "Synced to latest origin/{branch}",
"zh": "Synced to latest origin/{branch}" "zh": "Synced to latest origin/{branch}"
}, },
"Syncing {count} documentation pages to wiki...": { "Syncing files...": {
"bg": "Syncing {count} documentation pages to wiki...", "bg": "",
"de": "Syncing {count} documentation pages to wiki...", "de": "",
"en": "Syncing {count} documentation pages to wiki...", "en": "Syncing files...",
"pl": "Synchronizowanie {count} stron dokumentacji do wiki...", "pl": "",
"ru": "Syncing {count} documentation pages to wiki...", "ru": "",
"zh": "Syncing {count} documentation pages to wiki..." "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.": { "Tag consistency check failed.": {
"bg": "Tag consistency check failed.", "bg": "Tag consistency check failed.",
@@ -2439,6 +2895,14 @@
"ru": "Updated badge URLs in {filename}", "ru": "Updated badge URLs in {filename}",
"zh": "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}": { "Updated version in {init}": {
"bg": "Updated version in {init}", "bg": "Updated version in {init}",
"de": "Updated version in {init}", "de": "Updated version in {init}",
@@ -2455,6 +2919,14 @@
"ru": "Updated {changelog_file}", "ru": "Updated {changelog_file}",
"zh": "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.": { "VIKUNJA_TOKEN is not set. Required to derive PR title.": {
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.", "bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.", "de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
@@ -2511,6 +2983,38 @@
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.", "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 是否正确。" "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.": { "WARNING: --skip-tests passed — skipping test verification.": {
"bg": "WARNING: --skip-tests passed — skipping test verification.", "bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "WARNING: --skip-tests passed — skipping test verification.", "de": "WARNING: --skip-tests passed — skipping test verification.",
@@ -2527,22 +3031,6 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.", "ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。" "zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
}, },
"WARNING: 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.": { "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.", "bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.", "de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
@@ -2551,6 +3039,30 @@
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.", "ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: 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)...": { "Waiting for CI checks to complete (timeout: {timeout}s)...": {
"bg": "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)...", "de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
@@ -2615,21 +3127,37 @@
"ru": "Warning: repo-level runners query returned HTTP {status}", "ru": "Warning: repo-level runners query returned HTTP {status}",
"zh": "Warning: repo-level runners query returned HTTP {status}" "zh": "Warning: repo-level runners query returned HTTP {status}"
}, },
"Wiki integrity check failed — {count} issue(s)": { "Wiki repo not found or empty — initializing fresh.": {
"bg": "Wiki integrity check failed — {count} issue(s)", "bg": "",
"de": "Wiki integrity check failed — {count} issue(s)", "de": "",
"en": "Wiki integrity check failed — {count} issue(s)", "en": "Wiki repo not found or empty — initializing fresh.",
"pl": "Kontrola integralności wiki nie powiodła się — {count} problem(ów)", "pl": "",
"ru": "Wiki integrity check failed — {count} issue(s)", "ru": "",
"zh": "Wiki integrity check failed — {count} issue(s)" "zh": ""
}, },
"Wiki verification failed — {failures} page(s) empty or mismatched": { "Wiki synced successfully.": {
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched", "bg": "",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched", "de": "",
"en": "Wiki verification failed — {failures} page(s) empty or mismatched", "en": "Wiki synced successfully.",
"pl": "Weryfikacja wiki nie powiodła się — {failures} strona(y) pusta lub niezgodna", "pl": "",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched", "ru": "",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched" "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.": { "Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.", "bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
@@ -2639,6 +3167,14 @@
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.", "ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "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": { "[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[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", "de": "[check-dep-docs] Passed: all dependencies are documented",
@@ -2647,6 +3183,30 @@
"ru": "[check-dep-docs] Passed: all dependencies are documented", "ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented" "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": { "[check-mutable-globals] Passed: no mutable path globals found": {
"bg": "[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", "de": "[check-mutable-globals] Passed: no mutable path globals found",
@@ -2671,6 +3231,46 @@
"ru": "[check_test_coverage] No changed files to check.", "ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check." "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]": { "[dry-run] Would commit: release: v{version} [skip ci]": {
"bg": "[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]", "de": "[dry-run] Would commit: release: v{version} [skip ci]",
@@ -2703,13 +3303,13 @@
"ru": "[dry-run] Would push commit to master", "ru": "[dry-run] Would push commit to master",
"zh": "[dry-run] Would push commit to master" "zh": "[dry-run] Would push commit to master"
}, },
"[dry-run] Would sync page: {title} ({chars} chars)": { "[dry-run] Would update doc version references via check_doc_versions --fix": {
"bg": "[dry-run] Would sync page: {title} ({chars} chars)", "bg": "",
"de": "[dry-run] Would sync page: {title} ({chars} chars)", "de": "",
"en": "[dry-run] Would sync page: {title} ({chars} chars)", "en": "[dry-run] Would update doc version references via check_doc_versions --fix",
"pl": "[dry-run] Zsynchronizowano by stronę: {title} ({chars} znaków)", "pl": "",
"ru": "[dry-run] Would sync page: {title} ({chars} chars)", "ru": "",
"zh": "[dry-run] Would sync page: {title} ({chars} chars)" "zh": ""
}, },
"[dry-run] Would update {changelog_file}": { "[dry-run] Would update {changelog_file}": {
"bg": "[dry-run] Would update {changelog_file}", "bg": "[dry-run] Would update {changelog_file}",
@@ -2727,6 +3327,38 @@
"ru": "[dry-run] Would update {init}", "ru": "[dry-run] Would update {init}",
"zh": "[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}": { "[tool.devx] missing required keys: {keys}": {
"bg": "[tool.devx] липсват задължителни ключове: {keys}", "bg": "[tool.devx] липсват задължителни ключове: {keys}",
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}", "de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
@@ -2879,6 +3511,14 @@
"ru": "tea not installed — skipping login configuration.", "ru": "tea not installed — skipping login configuration.",
"zh": "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": { "unknown": {
"bg": "неизвестен", "bg": "неизвестен",
"de": "unbekannt", "de": "unbekannt",
@@ -2887,286 +3527,6 @@
"ru": "неизвестно", "ru": "неизвестно",
"zh": "未知" "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.": { "{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.", "en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.", "bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
@@ -3175,188 +3535,36 @@
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.", "ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。" "zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
}, },
"Login to {registry} failed: {error}": { "{env} is not set. Set it in your .env file.": {
"en": "Login to {registry} failed: {error}", "en": "{env} is not set. Set it in your .env file.",
"bg": "Влизането в {registry} не успя: {error}", "bg": "{env} не е зададен. Задайте го във вашия .env файл.",
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}", "de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
"pl": "Logowanie do {registry} nie powiodło się: {error}", "pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
"ru": "Ошибка входа в {registry}: {error}", "ru": "{env} не задан. Установите его в файле .env.",
"zh": "登录 {registry} 失败: {error}" "zh": "{env} 未设置。请在 .env 文件中设置。"
}, },
"tofu command failed in {dir}: {error}": { "{file} already exists. Use --force to overwrite.": {
"en": "tofu command failed in {dir}: {error}", "bg": "{file} already exists. Use --force to overwrite.",
"bg": "командата tofu не успя в {dir}: {error}", "de": "{file} already exists. Use --force to overwrite.",
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}", "en": "{file} already exists. Use --force to overwrite.",
"pl": "polecenie tofu nie powiodło się w {dir}: {error}", "pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
"ru": "команда tofu не удалась в {dir}: {error}", "ru": "{file} already exists. Use --force to overwrite.",
"zh": "tofu 命令在 {dir} 中失败: {error}" "zh": "{file} already exists. Use --force to overwrite."
}, },
"WARN: .venv has Python {version}, but >={req} is required.": { "{level}: {tool} not found.{hint}": {
"en": "WARN: .venv has Python {version}, but >={req} is required.", "en": "{level}: {tool} not found.{hint}",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.", "bg": "{level}: {tool} не е намерен.{hint}",
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.", "de": "{level}: {tool} nicht gefunden.{hint}",
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.", "pl": "{level}: {tool} nie znaleziono.{hint}",
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.", "ru": "{level}: {tool} не найден.{hint}",
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。" "zh": "{level}: 未找到 {tool}。{hint}"
}, },
" -> {dir}": { "{separator}": {
"en": " -> {dir}", "bg": "{separator}",
"bg": " -> {dir}", "de": "{separator}",
"de": " -> {dir}", "en": "{separator}",
"pl": " -> {dir}", "pl": "{separator}",
"ru": " -> {dir}", "ru": "{separator}",
"zh": " -> {dir}" "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}..."
},
"[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-{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 配置..."
},
"[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}] 所有配置有效。"
},
"[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}' 已存在(列表过期)。重新列出并更新..."
} }
} }
+62
View File
@@ -909,3 +909,65 @@ class TestGiteaClientActions:
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs", "https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
timeout=DEFAULT_TIMEOUT, 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
+12
View File
@@ -4,6 +4,7 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -249,3 +250,14 @@ class TestMain:
args, kwargs = mock_count.call_args args, kwargs = mock_count.call_args
assert "myorg" in args assert "myorg" in args
assert "myrepo" 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 "real_cmd" in commands
assert "pass" not 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: class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None: def test_finds_command_in_heading(self) -> None:
@@ -195,3 +227,171 @@ class TestMain:
result = runner.invoke(main, ["--docs-dir", str(docs)]) result = runner.invoke(main, ["--docs-dir", str(docs)])
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists # No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
assert result.exit_code == 0 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
+155
View File
@@ -9,11 +9,16 @@ from pathlib import Path
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.lint_docs import ( from devx.ci.lint_docs import (
check_code_block_languages,
check_docs_structure, check_docs_structure,
check_duplicate_headings, check_duplicate_headings,
check_heading_hierarchy, check_heading_hierarchy,
check_internal_links, check_internal_links,
check_line_length,
check_max_heading_depth,
check_orphan_docs,
check_required_files, check_required_files,
check_single_h1,
check_stale_docs, check_stale_docs,
check_todo_fixme, check_todo_fixme,
check_trailing_whitespace, check_trailing_whitespace,
@@ -362,6 +367,100 @@ class TestCheckDuplicateHeadings:
assert issues == [] assert issues == []
class TestCheckSingleH1:
def test_single_h1_ok(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Title\n## Section\n")
issues = check_single_h1(tmp_path)
assert issues == []
def test_multiple_h1_fails(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Title 1\n# Title 2\n")
issues = check_single_h1(tmp_path)
assert len(issues) == 1
assert "2 H1" in issues[0]
def test_no_h1_ok(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("## Section\n")
issues = check_single_h1(tmp_path)
assert issues == []
class TestCheckMaxHeadingDepth:
def test_ok(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# H1\n## H2\n### H3\n#### H4\n")
issues = check_max_heading_depth(tmp_path)
assert issues == []
def test_too_deep(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# H1\n##### H5\n")
issues = check_max_heading_depth(tmp_path)
assert len(issues) == 1
assert "H5" in issues[0]
class TestCheckLineLength:
def test_ok(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Short line\n")
issues = check_line_length(tmp_path)
assert issues == []
def test_too_long(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# " + "x" * 200 + "\n")
issues = check_line_length(tmp_path)
assert len(issues) == 1
assert "202" in issues[0]
class TestCheckCodeBlockLanguages:
def test_with_language(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("```python\nprint('hi')\n```\n")
issues = check_code_block_languages(tmp_path)
assert issues == []
def test_without_language(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("```\nplain text\n```\n")
issues = check_code_block_languages(tmp_path)
assert len(issues) == 1
assert "without language" in issues[0]
def test_closing_fence_not_flagged(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("```python\nprint('hi')\n```\n")
issues = check_code_block_languages(tmp_path)
assert issues == []
class TestCheckOrphanDocs:
def test_no_orphans(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n[link](page.md)\n")
(docs / "page.md").write_text("# Page\n")
issues = check_orphan_docs(tmp_path, docs)
assert issues == []
def test_orphan_found(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "page.md").write_text("# Page\n")
issues = check_orphan_docs(tmp_path, docs)
assert len(issues) == 1
assert "orphan" in issues[0]
def test_no_docs_dir(self, tmp_path: Path) -> None:
issues = check_orphan_docs(tmp_path, tmp_path / "docs")
assert issues == []
def test_referenced_in_mapping(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"page.md": "Page"}))
(docs / "page.md").write_text("# Page\n")
issues = check_orphan_docs(tmp_path, docs)
assert issues == []
class TestMain: class TestMain:
def test_passes_clean_repo(self, tmp_path: Path) -> None: def test_passes_clean_repo(self, tmp_path: Path) -> None:
"""A clean repo with all files should pass.""" """A clean repo with all files should pass."""
@@ -434,3 +533,59 @@ class TestMain:
# Stale docs are warnings, not errors # Stale docs are warnings, not errors
assert result.exit_code == 0 assert result.exit_code == 0
assert "stale" in result.output assert "stale" in result.output
def test_line_length_warning(self, tmp_path: Path) -> None:
"""--check-line-length should warn but not fail."""
(tmp_path / "README.md").write_text("# " + "x" * 200 + "\n")
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path), "--check-line-length"])
assert result.exit_code == 0
assert "long lines" in result.output
def test_line_length_many_warnings(self, tmp_path: Path) -> None:
"""More than 10 long lines should show '... and N more'."""
long_line = "x" * 200 + "\n"
(tmp_path / "README.md").write_text(long_line * 15)
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path), "--check-line-length"])
assert result.exit_code == 0
assert "more" in result.output
def test_orphan_docs_warning(self, tmp_path: Path) -> None:
"""--check-orphans should warn but not fail."""
(tmp_path / "README.md").write_text("# Title\n")
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
(docs / "orphan.md").write_text("# Orphan\n")
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path), "--check-orphans"])
assert result.exit_code == 0
assert "orphan" in result.output
def test_orphan_docs_invalid_mapping(self, tmp_path: Path) -> None:
"""Invalid mapping.json should not crash orphan check."""
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text("invalid json{")
(docs / "page.md").write_text("# Page\n")
# Should not raise — just returns issues
issues = check_orphan_docs(tmp_path, docs)
assert len(issues) == 1
assert "orphan" in issues[0]
+22
View File
@@ -186,6 +186,28 @@ class TestCli:
timeout=60, 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: def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise.""" """Pair with fewer than 2 parts should raise."""
from click.testing import CliRunner from click.testing import CliRunner
@@ -4,6 +4,7 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -219,3 +220,14 @@ class TestMain:
args, kwargs = mock_count.call_args args, kwargs = mock_count.call_args
assert "myorg" in args assert "myorg" in args
assert "myrepo" 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: class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: 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() 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 result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+7 -2
View File
@@ -153,9 +153,14 @@ class TestPrintLogs:
class TestCli: class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: 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() 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 result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+2 -1
View File
@@ -756,9 +756,10 @@ class TestMain:
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"}) result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0 assert result.exit_code != 0
@patch.dict("os.environ", {"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
def test_no_token_raises(self) -> None: def test_no_token_raises(self) -> None:
runner = CliRunner() 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 result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+7 -2
View File
@@ -122,9 +122,14 @@ class TestWaitForCompletion:
class TestCli: class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: 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() 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 result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+441 -552
View File
@@ -1,6 +1,7 @@
"""Unit tests for scripts/ci/sync_wiki.py.""" """Unit tests for devx.ci.sync_wiki (git-based approach)."""
from __future__ import annotations
import base64
import json import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -10,593 +11,481 @@ import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.sync_wiki import ( from devx.ci.sync_wiki import (
decode_content, clone_wiki,
encode_content, commit_and_push,
fetch_page_content, get_wiki_clone_url,
list_wiki_pages, init_wiki,
load_mapping, load_mapping,
main, main,
read_doc_content, sync_files,
sync_page, transform_links,
verify_wiki_integrity, wiki_filename,
verify_wiki_page,
) )
from devx.exceptions import APIError
class TestEncodeContent: class TestTransformLinks:
def test_encodes_utf8_to_base64(self) -> None: def test_removes_md_extension(self) -> None:
result = encode_content("# Hello World") result = transform_links("[link](page.md)")
assert result == base64.b64encode(b"# Hello World").decode("ascii") assert result == "[link](page)"
def test_encodes_empty_string(self) -> None: def test_removes_directory_prefix(self) -> None:
assert encode_content("") == "" result = transform_links("[link](docs/page.md)")
assert result == "[link](page)"
def test_encodes_unicode(self) -> None: def test_removes_parent_dir_prefix(self) -> None:
result = encode_content("# Café — résumé") result = transform_links("[link](../page.md)")
decoded = base64.b64decode(result).decode("utf-8") assert result == "[link](page)"
assert decoded == "# Café — résumé"
def test_preserves_external_links(self) -> None:
result = transform_links("[link](https://example.com)")
assert result == "[link](https://example.com)"
class TestDecodeContent: def test_preserves_http_links(self) -> None:
def test_decodes_base64_to_utf8(self) -> None: result = transform_links("[link](http://example.com)")
encoded = base64.b64encode(b"# Hello").decode("ascii") assert result == "[link](http://example.com)"
assert decode_content(encoded) == "# Hello"
def test_empty_string_returns_empty(self) -> None: def test_preserves_mailto(self) -> None:
assert decode_content("") == "" result = transform_links("[email](mailto:test@example.com)")
assert result == "[email](mailto:test@example.com)"
def test_roundtrip(self) -> None: def test_preserves_anchor_only(self) -> None:
original = "# Wiki Page\n\nContent with **markdown**." result = transform_links("[section](#section)")
encoded = encode_content(original) assert result == "[section](#section)"
assert decode_content(encoded) == original
def test_preserves_anchor_with_path(self) -> None:
result = transform_links("[section](page.md#section)")
assert result == "[section](page#section)"
def test_no_links_unchanged(self) -> None:
text = "# Title\n\nSome text without links.\n"
assert transform_links(text) == text
def test_multiple_links(self) -> None:
result = transform_links("[a](one.md) and [b](two.md)")
assert result == "[a](one) and [b](two)"
class TestLoadMapping: class TestLoadMapping:
def test_loads_mapping(self, tmp_path: Path) -> None: def test_loads_mapping(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mapping_file = tmp_path / "mapping.json" mapping_file = tmp_path / "docs" / "mapping.json"
mapping_file.write_text(json.dumps({"user/getting-started.md": "Getting-Started"})) mapping_file.parent.mkdir()
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file): mapping_file.write_text(json.dumps({"index.md": "Home", "guide.md": "Guide"}))
result = load_mapping() monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
assert result == {"user/getting-started.md": "Getting-Started"} mapping = load_mapping()
assert mapping == {"index.md": "Home", "guide.md": "Guide"}
def test_missing_mapping_raises(self, tmp_path: Path) -> None: def test_non_dict_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
with patch("devx.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"): mapping_file = tmp_path / "docs" / "mapping.json"
with pytest.raises(FileNotFoundError): mapping_file.parent.mkdir()
load_mapping()
def test_non_dict_mapping_raises(self, tmp_path: Path) -> None:
"""Non-dict mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('["not", "a", "dict"]') mapping_file.write_text('["not", "a", "dict"]')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file): monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
with pytest.raises(click.ClickException, match="must be a dict"): with pytest.raises(click.ClickException, match="must be a dict"):
load_mapping() load_mapping()
def test_non_string_values_raise(self, tmp_path: Path) -> None: def test_non_string_values_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-string values in mapping.json should raise.""" mapping_file = tmp_path / "docs" / "mapping.json"
mapping_file = tmp_path / "mapping.json" mapping_file.parent.mkdir()
mapping_file.write_text('{"file.md": 123}') mapping_file.write_text(json.dumps({"key": 123}))
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file): monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
with pytest.raises(click.ClickException, match="must be strings"): with pytest.raises(click.ClickException, match="must be strings"):
load_mapping() load_mapping()
class TestReadDocContent: class TestGetWikiCloneUrl:
def test_reads_file(self, tmp_path: Path) -> None: def test_builds_url(self) -> None:
docs_dir = tmp_path / "docs" url = get_wiki_clone_url("owner", "repo", "token")
docs_dir.mkdir() assert "owner/repo.wiki.git" in url
(docs_dir / "test.md").write_text("# Test\n\nContent")
with patch("devx.ci.sync_wiki.DOCS_DIR", docs_dir):
content = read_doc_content("test.md")
assert content == "# Test\n\nContent"
def test_missing_file_raises(self, tmp_path: Path) -> None:
with patch("devx.ci.sync_wiki.DOCS_DIR", tmp_path):
with pytest.raises(FileNotFoundError):
read_doc_content("nonexistent.md")
class TestListWikiPages: class TestWikiFilename:
def test_raises_on_api_error(self) -> None: def test_no_dashes(self) -> None:
client = MagicMock() assert wiki_filename("Home") == "Home.md"
client._request.side_effect = APIError(404, "not found")
with pytest.raises(APIError):
list_wiki_pages(client)
def test_returns_page_dict(self) -> None: def test_single_word(self) -> None:
client = MagicMock() assert wiki_filename("Architecture") == "Architecture.md"
client._request.return_value.json.return_value = [
{"title": "Home", "sub_url": "Home"}, def test_with_dashes_adds_marker(self) -> None:
{"title": "Getting-Started", "sub_url": "Getting-Started.-"}, 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:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = clone_wiki("https://example.com/repo.wiki.git", tmp_path / "wiki")
assert result is True
@patch("devx.ci.sync_wiki.subprocess.run")
def test_clone_failure_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not found")
result = clone_wiki("https://example.com/repo.wiki.git", tmp_path / "wiki")
assert result is False
class TestInitWiki:
@patch("devx.ci.sync_wiki.subprocess.run")
def test_init_calls_git(self, mock_run: MagicMock, tmp_path: Path) -> None:
wiki_dir = tmp_path / "wiki"
init_wiki(wiki_dir)
assert wiki_dir.exists()
calls = [c.args[0] for c in mock_run.call_args_list]
assert ["git", "init"] in calls
assert ["git", "config", "user.email", "ci@oblachno.fyi"] in calls
class TestSyncFiles:
def test_syncs_files(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n[link](page.md)\n")
(docs / "page.md").write_text("# Page\n")
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"index.md": "Home", "page.md": "Page"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 2
assert pruned == 0
assert (wiki / "Home.md").exists()
assert (wiki / "Page.md").exists()
# Check link transformation
content = (wiki / "Home.md").read_text()
assert "[link](page)" in content
def test_prunes_stale(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
wiki = tmp_path / "wiki"
wiki.mkdir()
(wiki / "OldPage.md").write_text("# Old\n")
(wiki / "Home.md").write_text("# Old Home\n")
mapping = {"index.md": "Home"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 1
assert pruned == 1 # OldPage.md pruned, Home.md overwritten
assert not (wiki / "OldPage.md").exists()
assert (wiki / "Home.md").exists()
def test_dry_run_no_writes(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"index.md": "Home"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=True)
assert synced == 1
assert pruned == 0
assert not (wiki / "Home.md").exists()
def test_missing_file_warns(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"missing.md": "Missing"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 0
def test_empty_file_warns(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "empty.md").write_text("")
wiki = tmp_path / "wiki"
wiki.mkdir()
mapping = {"empty.md": "Empty"}
synced, pruned = sync_files(docs, wiki, mapping, dry_run=False)
assert synced == 0
class TestCommitAndPush:
@patch("devx.ci.sync_wiki.subprocess.run")
def test_dry_run_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
result = commit_and_push(tmp_path, "url", dry_run=True)
assert result is False
mock_run.assert_not_called()
@patch("devx.ci.sync_wiki.subprocess.run")
def test_no_changes_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
# git add succeeds, git diff --cached --quiet returns 0 (no changes)
mock_run.side_effect = [
MagicMock(returncode=0), # git add
MagicMock(returncode=0), # git diff --cached --quiet (no changes)
] ]
result = list_wiki_pages(client) result = commit_and_push(tmp_path, "url", dry_run=False)
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"} assert result is False
@patch("devx.ci.sync_wiki.subprocess.run")
def test_pushes_changes(self, mock_run: MagicMock, tmp_path: Path) -> None:
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
]
result = commit_and_push(tmp_path, "url", dry_run=False)
assert result is True
class TestFetchPageContent: @patch("devx.ci.sync_wiki.subprocess.run")
def test_fetches_and_decodes_content(self) -> None: def test_push_failure_returns_false(self, mock_run: MagicMock, tmp_path: Path) -> None:
client = MagicMock() mock_run.side_effect = [
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii") MagicMock(returncode=0), # git add
client._request.return_value.json.return_value = {"content_base64": encoded} MagicMock(returncode=1), # git diff --cached --quiet (has changes)
result = fetch_page_content(client, "Home") MagicMock(returncode=0), # git config user.email
assert result == "# Hello Wiki" MagicMock(returncode=0), # git config user.name
MagicMock(returncode=0), # git commit
def test_returns_empty_on_api_error(self) -> None: MagicMock(returncode=1, stdout="", stderr="push failed"), # git push
from devx.exceptions import APIError ]
result = commit_and_push(tmp_path, "url", dry_run=False)
client = MagicMock() assert result is False
client._request.side_effect = APIError(404, "not found")
assert fetch_page_content(client, "Missing") == ""
def test_returns_empty_for_empty_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
assert fetch_page_content(client, "Home") == ""
class TestSyncPage:
def test_dry_run_skips(self) -> None:
client = MagicMock()
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=True)
assert result == "skipped"
client._request.assert_not_called()
def test_creates_new_page_with_base64(self) -> None:
client = MagicMock()
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
assert result == "created"
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args.args[0] == "POST"
assert call_args.args[1] == "/wiki/new"
# Verify content_base64 is used, not content
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content"
def test_updates_existing_page_with_base64(self) -> None:
client = MagicMock()
existing = {"Existing-Page": "Existing-Page.-"}
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
assert result == "updated"
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args.args[0] == "PATCH"
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
# Verify content_base64 is used
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
def test_create_falls_back_to_update_on_already_exists(self) -> None:
"""When create fails with 400 'already exists', re-list and update."""
client = MagicMock()
# First call: POST /wiki/new → 400 already exists
# Second call: PATCH /wiki/page/{sub_url} → success
create_error = APIError(400, "wiki page already exists [title: Test-Page]")
client._request.side_effect = [create_error, MagicMock()]
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", return_value={"Test-Page": "Test-Page.-"}):
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
assert result == "updated"
# Verify PATCH was called (second call)
patch_call = client._request.call_args_list[1]
assert patch_call.args[0] == "PATCH"
assert "/wiki/page/Test-Page.-" in patch_call.args[1]
def test_create_raises_non_400_error(self) -> None:
"""Non-400 errors from create should propagate, not trigger fallback."""
client = MagicMock()
client._request.side_effect = APIError(500, "server error")
with pytest.raises(APIError):
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
def test_create_raises_400_not_already_exists(self) -> None:
"""400 errors that don't mention 'already exists' should propagate."""
client = MagicMock()
client._request.side_effect = APIError(400, "invalid title")
with pytest.raises(APIError):
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
class TestVerifyWikiPage:
def test_verifies_matching_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True
def test_fails_on_mismatch(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Old Content").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# New Content", existing) is False
def test_fails_on_empty_wiki_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Expected", existing) is False
def test_fails_when_page_not_in_existing(self) -> None:
client = MagicMock()
assert verify_wiki_page(client, "Missing", "# Content", {}) is False
class TestVerifyWikiIntegrity:
def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock:
"""Create a mock client that returns the given pages and contents."""
client = MagicMock()
# list_wiki_pages calls GET /wiki/pages
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
# fetch_page_content calls GET /wiki/page/{sub_url}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
return client
def test_all_good_no_failures(self) -> None:
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_missing_page_detected(self) -> None:
pages = {"Home": "Home"} # FAQ missing from wiki
contents = {"Home": "# Home"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Missing page: FAQ" in f for f in failures)
def test_stale_page_detected(self) -> None:
pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping
contents = {"Home": "# Home", "Old-Page": "# Old"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Stale page" in f and "Old-Page" in f for f in failures)
def test_page_count_mismatch_detected(self) -> None:
pages = {"Home": "Home", "Extra": "Extra"}
contents = {"Home": "# Home", "Extra": "# Extra"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Page count mismatch" in f for f in failures)
def test_empty_content_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": ""} # Empty content
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Expected Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Empty content: Home" in f for f in failures)
def test_content_mismatch_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": "# Wrong Content"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Correct Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Content mismatch: Home" in f for f in failures)
def test_multiple_failures_all_reported(self) -> None:
pages = {"Home": "Home", "Stale": "Stale"}
contents = {"Home": "", "Stale": "# Stale"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing
synced = {"Home": "# Home Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
def test_transient_api_failure_returns_empty(self) -> None:
"""When the wiki API is unavailable after retries, integrity check
should return no failures (sync already succeeded)."""
client = MagicMock()
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_transient_api_failure_recovers_on_retry(self) -> None:
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
client = MagicMock()
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
class TestMain: class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@patch("devx.ci.sync_wiki.MAPPING_FILE") for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
@patch("devx.ci.sync_wiki.DOCS_DIR") monkeypatch.delenv(name, raising=False)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None:
mock_mapping_file.exists.return_value = True
mock_mapping_file.__str__ = lambda _: "/docs/mapping.json"
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "dry-run" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"]) result = runner.invoke(main, [], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code == 1 assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict( def test_no_mapping_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
) monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json")
@patch("devx.ci.sync_wiki.GiteaClient") runner = CliRunner()
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None: result = runner.invoke(main, ["--repo", "owner/repo"])
"""Test that repo is auto-detected from env vars when --repo is not passed.""" assert result.exit_code != 0
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
mock_client_cls.assert_called_once()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
"""Test that missing mapping.json exits with error."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = False
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1
assert "mapping.json" in result.output assert "mapping.json" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None: @patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
"""Test that existing wiki pages are reported.""" def test_dry_run(
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: self,
mock_mapping.exists.return_value = True mock_sync: MagicMock,
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): mock_push: MagicMock,
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): mock_clone: MagicMock,
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): tmp_path: Path,
runner = CliRunner() monkeypatch: pytest.MonkeyPatch,
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) ) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "existing wiki pages" in result.output assert "dry-run" in result.output
mock_push.assert_not_called()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None: @patch("devx.ci.sync_wiki.sync_files", return_value=(2, 0))
"""Test that missing doc files cause an error, not a warning.""" def test_full_sync(
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: self,
mock_mapping.exists.return_value = True mock_sync: MagicMock,
with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}): mock_push: MagicMock,
with patch("devx.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError): mock_clone: MagicMock,
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}): tmp_path: Path,
runner = CliRunner() monkeypatch: pytest.MonkeyPatch,
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) ) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n[link](page.md)\n")
(docs / "page.md").write_text("# Page\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home", "page.md": "Page"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
assert "Synced" in result.output
mock_push.assert_called_once()
@patch("devx.ci.sync_wiki.clone_wiki", return_value=False)
@patch("devx.ci.sync_wiki.init_wiki")
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_init_fresh_wiki(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_init: MagicMock,
mock_clone: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
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))
def test_verify(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
# Mock clone_wiki to create the wiki dir with the expected file
def fake_clone(url: str, dest: Path) -> bool:
dest.mkdir(parents=True, exist_ok=True)
(dest / "Home.md").write_text("# Home\n")
return True
mock_clone.side_effect = fake_clone
runner = CliRunner()
result = runner.invoke(main, ["--verify", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Verification" in result.output
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.commit_and_push", return_value=False)
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
def test_push_failed_message(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
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))
def test_verify_clone_fails(
self,
mock_sync: MagicMock,
mock_push: MagicMock,
mock_clone: MagicMock,
mock_sleep: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, ["--verify", "--repo", "owner/repo"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "not found" in result.output assert "could not clone" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.time.sleep")
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.clone_wiki")
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None: @patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
"""Test that empty doc files cause an error, not a warning.""" @patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: def test_verify_missing_page(
mock_mapping.exists.return_value = True self,
with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}): mock_sync: MagicMock,
with patch("devx.ci.sync_wiki.read_doc_content", return_value=" \n "): mock_push: MagicMock,
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}): mock_clone: MagicMock,
runner = CliRunner() mock_sleep: MagicMock,
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
# Mock clone_wiki to create the wiki dir WITHOUT the expected file
def fake_clone(url: str, dest: Path) -> bool:
dest.mkdir(parents=True, exist_ok=True)
return True
mock_clone.side_effect = fake_clone
runner = CliRunner()
result = runner.invoke(main, ["--verify", "--repo", "owner/repo"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "empty" in result.output.lower() assert "page(s) missing" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
def test_create_and_update(self, mock_client_cls: MagicMock) -> None: @patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
"""Test that pages are created and updated correctly (non-dry-run).""" def test_auto_detect_repo(
mock_client = MagicMock() self,
mock_client_cls.return_value = mock_client mock_sync: MagicMock,
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_push: MagicMock,
mock_mapping.exists.return_value = True mock_clone: MagicMock,
mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"} tmp_path: Path,
with patch("devx.ci.sync_wiki.load_mapping", return_value=mapping): monkeypatch: pytest.MonkeyPatch,
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Content"): ) -> None:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}): docs = tmp_path / "docs"
runner = CliRunner() docs.mkdir()
result = runner.invoke(main, ["--repo", "owner/repo"]) (docs / "index.md").write_text("# Home\n")
mapping_file = docs / "mapping.json"
mapping_file.write_text(json.dumps({"index.md": "Home"}))
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", mapping_file)
monkeypatch.setattr("devx.ci.sync_wiki.DOCS_DIR", docs)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Created: 1" in result.output
assert "Updated: 1" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify passes when content matches."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
encoded = base64.b64encode(b"# Home Content").decode("ascii")
# list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content
mock_client._request.return_value.json.return_value = {"content_base64": encoded}
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.verify_wiki_page", return_value=True):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Verification passed" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify fails when wiki pages have empty content."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.verify_wiki_page", return_value=False):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 1
assert "FAIL" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify is skipped during dry-run."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--verify", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Verification" not in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict passes when integrity check succeeds."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.verify_wiki_integrity", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 0
assert "Integrity check passed" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict fails when integrity check finds issues."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch(
"devx.ci.sync_wiki.verify_wiki_integrity",
return_value=["Missing page: FAQ", "Stale page: Old-Page"],
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 1
assert "Integrity check FAILED" in result.output
assert "Missing page: FAQ" in result.output
assert "Stale page: Old-Page" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict verification is skipped during dry-run."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Integrity check" not in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_initial_list_api_error_aborts(self, mock_client_cls: MagicMock) -> None:
"""When the initial page list fails after retries, sync aborts to avoid duplicate pages."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code != 0
assert "Failed to list existing wiki pages" in result.output
assert "Aborting" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
# Initial list succeeds, but verify re-fetch fails
list_side_effect = [{"Home": "Home"}, APIError(0, "timeout")]
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=list_side_effect):
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Skipping content verification" in result.output