Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c244881f22 | ||
|
|
4cde7de696 | ||
|
|
d675889604 | ||
|
|
e23138e731 | ||
|
|
ef3b882e5b | ||
|
|
8d9ee1ea26 | ||
|
|
1497b29487 | ||
|
|
cb126e83da | ||
|
|
281193c741 | ||
|
|
0228fce5b9 | ||
|
|
981d3e41cc | ||
|
|
3cd2459eef | ||
|
|
ef08513bcf | ||
|
|
05922eca2f | ||
|
|
05de2b0aa9 | ||
|
|
6a463a93d2 | ||
|
|
ad7b52c368 | ||
|
|
6b81e1a50a | ||
|
|
443dc01b4e | ||
|
|
1d7bf7118a | ||
|
|
f98534ebe2 | ||
|
|
c62b168b25 | ||
|
|
40a94df029 | ||
|
|
f50c4c1e00 | ||
|
|
bbb264efc9 | ||
|
|
c97b249935 |
+15
-2
@@ -1,6 +1,19 @@
|
||||
# Gitea API token (required for CI scripts that interact with Gitea)
|
||||
# Role-based Gitea API tokens.
|
||||
# Each token serves a specific role. For small teams the developer and CI
|
||||
# tokens may belong to the same user, but the reviewer token MUST belong to a
|
||||
# different Gitea user than the PR author so Gitea accepts approval reviews.
|
||||
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
|
||||
CI_GITEA_TOKEN=
|
||||
|
||||
# Developer token — used by local tooling: create-task, create-pr, setup, etc.
|
||||
DEVELOPER_GITEA_API_TOKEN=
|
||||
|
||||
# CI token — used by CI workflows and scripts that do not post approvals.
|
||||
# Legacy CI_GITEA_TOKEN is also accepted.
|
||||
CI_GITEA_API_TOKEN=
|
||||
|
||||
# Reviewer token — used by the auto-merge workflow to post APPROVE reviews.
|
||||
# This must be a different Gitea user from the developer/CI user.
|
||||
REVIEWER_GITEA_API_TOKEN=
|
||||
|
||||
# Vikunja API token (required for post-merge task updates)
|
||||
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens
|
||||
|
||||
@@ -38,6 +38,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-ci
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
@@ -62,18 +64,22 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Docker registry login
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
_TOKEN="$CI_GITEA_API_TOKEN"
|
||||
[ -z "$_TOKEN" ] && _TOKEN="$DEVELOPER_GITEA_API_TOKEN"
|
||||
[ -z "$_TOKEN" ] && _TOKEN="$CI_GITEA_TOKEN"
|
||||
if [ -z "$_TOKEN" ]; then echo "Gitea API token not set — skipping Docker login"; exit 1; fi
|
||||
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
- name: Build and push tier images
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
@@ -103,7 +109,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -125,10 +131,12 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-ci
|
||||
- name: Clean up old image versions
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
|
||||
+18
-25
@@ -16,6 +16,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Lint all
|
||||
run: |
|
||||
@@ -32,31 +34,15 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
- name: Documentation coverage check
|
||||
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
|
||||
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||
DEVX_VALE_LEVEL: warning
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
vale --minAlertLevel=error docs/ AGENTS.md README.md
|
||||
make devx-docs-check
|
||||
- name: Translation completeness check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
@@ -95,6 +81,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
@@ -122,10 +110,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Release dry-run validation
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -143,10 +132,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Run automated PR review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -176,12 +167,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Post approval review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
@@ -196,7 +189,7 @@ jobs:
|
||||
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -47,6 +47,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
@@ -70,6 +72,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Validate latest commit message
|
||||
env:
|
||||
@@ -95,10 +99,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Configure git
|
||||
run: |
|
||||
@@ -107,6 +111,7 @@ jobs:
|
||||
- name: Run release
|
||||
id: release-tag
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -115,7 +120,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -142,10 +147,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.release.outputs.tag }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image EXTRAS=release
|
||||
- name: Build and publish release
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -154,7 +161,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -183,10 +190,12 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Sync documentation to wiki
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -194,7 +203,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -219,15 +228,18 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
- name: Fetch latest master
|
||||
run: |
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -235,7 +247,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -260,6 +272,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
@@ -272,7 +286,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -295,10 +309,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_REPO_NAME: devx
|
||||
DEVX_REPO_OWNER: oblachno-oss
|
||||
@@ -308,7 +324,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
+4
-10
@@ -73,18 +73,12 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: doc-coverage
|
||||
name: documentation coverage check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: lint-docs
|
||||
name: documentation lint check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
|
||||
- id: docs-check
|
||||
name: documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||
entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_VALE_LEVEL=warning make devx-docs-check'
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
|
||||
@@ -32,14 +32,17 @@ write-good.E-Prime = NO
|
||||
write-good.So = NO
|
||||
write-good.ThereIs = NO
|
||||
write-good.TooWordy = NO
|
||||
write-good.Passive = NO
|
||||
|
||||
# Vale defaults — spelling catches too many technical terms
|
||||
Vale.Terms = NO
|
||||
Vale.Repetition = NO
|
||||
Vale.Spelling = NO
|
||||
|
||||
# Readability — warnings only, technical docs are naturally complex
|
||||
# Readability — technical docs are naturally complex, downgrade to suggestions
|
||||
Readability.FleschReadingEase = suggestion
|
||||
Readability.FleschKincaid = suggestion
|
||||
Readability.AutomatedReadability = suggestion
|
||||
Readability.ColemanLiau = suggestion
|
||||
Readability.LIX = suggestion
|
||||
Readability.GunningFog = suggestion
|
||||
|
||||
@@ -3,4 +3,4 @@ message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
|
||||
level: warning
|
||||
scope: raw
|
||||
raw:
|
||||
- '(?s)```\n(?!.*```)'
|
||||
- '(?ms)^\n```\n.*?^```\s*$'
|
||||
|
||||
@@ -2,7 +2,7 @@ Based on [write-good](https://github.com/btford/write-good).
|
||||
|
||||
> Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too.
|
||||
|
||||
```text
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Brian Ford
|
||||
|
||||
@@ -88,7 +88,9 @@ src/devx/
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ ├── doc_coverage.py # Documentation coverage check
|
||||
│ └── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
|
||||
│ ├── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
|
||||
│ ├── validate_deploy_ref.py # Validate git tag for deployments (--github-output)
|
||||
│ └── record_deployed_tag.py # Record deployed tag to Gitea repo variable
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
|
||||
@@ -113,6 +115,16 @@ src/devx/
|
||||
│ ├── pre_push_check.py # Validate Vikunja task existence before push
|
||||
│ └── _shared.py # Shared tool utilities
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
├── utils/ # Shared utilities (reusable across projects)
|
||||
│ ├── api.py # API response helpers (is_truthy, is_falsy)
|
||||
│ ├── ssh.py # SSH exec + wait_for_ssh (pure-Python socket check)
|
||||
│ ├── crypto.py # Secret generation (shell-safe passwords)
|
||||
│ ├── vault.py # Ansible vault encrypt/decrypt helpers
|
||||
│ ├── network.py # HTTP connectivity check + wait_for_ssh
|
||||
│ ├── confirm.py # Typed confirmation validation for destructive ops
|
||||
│ ├── json_registry.py # File-locked JSON registry for local state
|
||||
│ ├── step_tracker.py # Multi-step operation tracking with reports
|
||||
│ └── logging.py # XDG-compliant logging configuration
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||
@@ -148,6 +160,12 @@ The following rules are enforced for `master`:
|
||||
### 1. Create Vikunja Task
|
||||
Create a task in Vikunja to get a `DEVX-N` identifier.
|
||||
|
||||
**IMPORTANT:** The task title must NOT include the `DEVX-N:` prefix.
|
||||
The `make create-pr` and `check_auto_merge_ready` commands automatically
|
||||
prepend `DEVX-N: ` to the Vikunja task title when forming the PR title.
|
||||
If the Vikunja task title already includes the prefix, the PR title will
|
||||
have a double prefix and auto-merge validation will fail.
|
||||
|
||||
### 2. Create Branch
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
@@ -226,14 +244,14 @@ After a PR is merged to master, the **post-merge workflow**
|
||||
- Pushes both the commit and tag to master
|
||||
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
|
||||
non-release commits (not just when release succeeds), so docs-only
|
||||
non-release commits (not only when release succeeds), so docs-only
|
||||
changes still update the wiki.
|
||||
|
||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||
Uses `if: always()` so it runs on every push, including release commits.
|
||||
|
||||
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
|
||||
non-release commits (not just when release succeeds), so infrastructure-only
|
||||
non-release commits (not only when release succeeds), so infrastructure-only
|
||||
changes still update the task tracker.
|
||||
|
||||
6. **publish** — Runs after release succeeds (needs: release). Builds and
|
||||
@@ -398,7 +416,7 @@ system loads `.env` automatically via `python-dotenv`.
|
||||
|
||||
### pyproject.toml [tool.devx] Configuration
|
||||
|
||||
In addition to `DEVX_` env vars, several devx tools read configuration from
|
||||
In addition to `DEVX_` env vars, many devx tools read configuration from
|
||||
the `[tool.devx]` section in `pyproject.toml`. This allows per-project
|
||||
customization without environment variables.
|
||||
|
||||
@@ -610,7 +628,7 @@ the user should not need to specify which profile to use.
|
||||
2. **Background by default, foreground when blocking.**
|
||||
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
|
||||
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
|
||||
5. **Don't delegate trivial work** (<30s, <50 lines of context).
|
||||
5. **Don't delegate minor work** (<30s, <50 lines of context).
|
||||
6. **Compact after subagent returns.**
|
||||
7. **Never skip delegation to save time** — it keeps main context small.
|
||||
|
||||
|
||||
@@ -2,6 +2,60 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.40.0] - 2026-07-11
|
||||
|
||||
### Features
|
||||
|
||||
- Detect double-prefix in Vikunja task title during pre-merge validation
|
||||
|
||||
## [0.39.0] - 2026-07-09
|
||||
|
||||
### Features
|
||||
|
||||
- Extract shared utilities from infra and grm into devx
|
||||
|
||||
## [0.38.0] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- Introduce role-based Gitea API token environment variables
|
||||
|
||||
## [0.37.0] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- Consolidate docs checks into devx-docs-check target
|
||||
|
||||
## [0.36.2] - 2026-07-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- GiteaClient.set_repo_variable uses PUT instead of PATCH
|
||||
|
||||
## [0.36.1] - 2026-07-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Preserve .badges/ dir during git clean in push_badges
|
||||
|
||||
## [0.36.0] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add GiteaClient repo variable methods and parallelize pytest-cov
|
||||
|
||||
## [0.35.7] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use Gitea wiki dash-marker filename convention
|
||||
|
||||
## [0.35.6] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add delay before wiki verification to avoid race condition
|
||||
|
||||
## [0.35.5] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.35.5",
|
||||
"devx>=0.40.0",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
@@ -101,8 +101,8 @@ pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (for example, `"devx==0.35.5"`) or use a version constraint
|
||||
> (for example, `"devx>=0.35.5,<0.36"`).
|
||||
> `dependencies` (for example, `"devx==0.40.0"`) or use a version constraint
|
||||
> (for example, `"devx>=0.40.0,<0.41"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
@@ -372,7 +372,7 @@ infrastructure = []
|
||||
|
||||
# Files that would default to user-facing but are actually infrastructure
|
||||
infrastructure_overrides = [
|
||||
"src/myproject/__init__.py", # only contains __version__
|
||||
"src/myproject/__init__.py", # example only — only contains __version__
|
||||
]
|
||||
|
||||
# Safety override for broad infrastructure patterns
|
||||
|
||||
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir /tmp/devx[lint] \
|
||||
&& rm -rf /tmp/devx
|
||||
|
||||
# Install CI/CD binary tools
|
||||
RUN python3 -m devx.tools.install_tools --tool actionlint \
|
||||
RUN python3 -m devx.tools.install_tools --tool actionlint --tool vale \
|
||||
&& python3 -m devx.tools.install_checkmake
|
||||
|
||||
# Install hadolint (Dockerfile linter)
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.35.5",
|
||||
"devx>=0.40.0",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.35.5"` or `"devx>=0.35.5,<0.36"`.
|
||||
Pin a specific version if needed: `"devx==0.40.0"` or `"devx>=0.40.0,<0.41"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -122,7 +122,9 @@ exponential backoff (2s, 4s, 8s).
|
||||
- Labels (list, create, add to issues)
|
||||
- Issues (create, list)
|
||||
- Pull requests (get commits, merge, create review)
|
||||
- Releases (list)
|
||||
- Releases (list, create idempotent)
|
||||
- Actions (list runs, list jobs, get job logs)
|
||||
- Actions variables (get, set idempotent)
|
||||
- Wiki pages (list, fetch, create, update, delete)
|
||||
|
||||
**`VikunjaClient`** — Vikunja REST API wrapper:
|
||||
@@ -309,7 +311,7 @@ from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
|
||||
### `setup.py`
|
||||
|
||||
Project setup: installs Python dependencies (editable mode with extras),
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit
|
||||
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
|
||||
profile from `.env`. Supports `--extras` to specify dependency groups,
|
||||
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
|
||||
|
||||
@@ -251,7 +251,7 @@ badges using `python -m devx.ci.push_badges`:
|
||||
|
||||
1. **Fetch latest master** — `git fetch origin master && git reset --hard
|
||||
origin/master` (ensures the version badge reflects the current state,
|
||||
even if the release job just pushed a new version)
|
||||
even if the release job recently pushed a new version)
|
||||
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
||||
|
||||
@@ -405,7 +405,7 @@ devx tools install-tools --list # list status
|
||||
### `devx tools setup`
|
||||
|
||||
Project setup: install Python dependencies (editable mode with extras),
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit
|
||||
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
|
||||
profile from `.env`.
|
||||
|
||||
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.35.5",
|
||||
"devx>=0.40.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.35.5",
|
||||
"devx>=0.40.0",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -121,6 +121,12 @@ vikunja_project_id = 8
|
||||
repo_owner = "oblachno-oss"
|
||||
repo_name = "devx"
|
||||
|
||||
[tool.devx.check_agent_docs]
|
||||
skip_ref_prefixes = [
|
||||
"src/myproject/",
|
||||
"ansible/requirements.yml",
|
||||
]
|
||||
|
||||
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||
# 4. Default: user-facing (safe)
|
||||
[tool.devx.classify]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.35.5"
|
||||
__version__ = "0.40.0"
|
||||
|
||||
@@ -372,6 +372,36 @@ class GiteaClient:
|
||||
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
|
||||
return r.text
|
||||
|
||||
# -- actions variables (repo-level) --
|
||||
|
||||
def get_repo_variable(self, name: str) -> str | None:
|
||||
"""Read a Gitea Actions repository variable.
|
||||
|
||||
Returns the variable value, or ``None`` if the variable is not set.
|
||||
Raises :class:`APIError` on other HTTP errors.
|
||||
"""
|
||||
try:
|
||||
r = self._request("GET", f"/actions/variables/{name}")
|
||||
return r.json().get("value")
|
||||
except APIError as e:
|
||||
if e.status == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def set_repo_variable(self, name: str, value: str) -> None:
|
||||
"""Create or update a Gitea Actions repository variable (idempotent).
|
||||
|
||||
Tries PUT first (update); if the variable doesn't exist (404),
|
||||
creates it via POST. Gitea 1.26.x does not support PATCH for
|
||||
action variables.
|
||||
"""
|
||||
try:
|
||||
self._request("PUT", f"/actions/variables/{name}", json={"value": value})
|
||||
except APIError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
self._request("POST", f"/actions/variables/{name}", json={"value": value})
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
|
||||
@@ -17,10 +17,9 @@ This allows the PR title to be a human-friendly Vikunja task title
|
||||
while the squashed commit follows conventional commits.
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
|
||||
CI_GITEA_API_TOKEN=<token> VIKUNJA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -40,6 +39,7 @@ from devx.config import (
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token, get_vikunja_token
|
||||
|
||||
# Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects.
|
||||
_TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*")
|
||||
@@ -115,9 +115,12 @@ def get_vikunja_task_title(task_id: str) -> str:
|
||||
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
|
||||
try:
|
||||
token = get_vikunja_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(
|
||||
_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.")
|
||||
) from None
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
@@ -197,9 +200,10 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
@click.argument("repo")
|
||||
@click.argument("pr_number")
|
||||
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
# Validate PR number is an integer
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
|
||||
|
||||
Usage::
|
||||
|
||||
# CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
|
||||
# CI (with VIKUNJA_TOKEN and CI_GITEA_API_TOKEN):
|
||||
python3 -m devx.ci.check_auto_merge_ready \\
|
||||
--branch "$HEAD_REF" \\
|
||||
--pr-title "$PR_TITLE" \\
|
||||
@@ -34,13 +34,12 @@ skipped (with a warning) — this allows local pre-push hooks to run
|
||||
without CI secrets. In CI, the token is always set and the check is
|
||||
mandatory.
|
||||
|
||||
If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only
|
||||
If ``CI_GITEA_API_TOKEN`` is not set and ``--pr-number`` is not provided, only
|
||||
branch-name and PR-title-format checks run (local mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
@@ -55,6 +54,7 @@ from devx.config import (
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token, get_vikunja_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -99,10 +99,13 @@ def is_branch_behind_master(branch: str) -> bool:
|
||||
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||
"""Fetch the PR title from the Gitea API.
|
||||
|
||||
Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched.
|
||||
Returns ``None`` if no token is set or the PR cannot be fetched.
|
||||
"""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token or "/" not in repo:
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
return None
|
||||
if "/" not in repo:
|
||||
return None
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
@@ -120,8 +123,9 @@ def get_vikunja_title_optional(task_id: str) -> str | None:
|
||||
raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the
|
||||
caller can skip the check in local mode.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
try:
|
||||
token = get_vikunja_token()
|
||||
except click.ClickException:
|
||||
return None
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
@@ -221,7 +225,11 @@ def cli(
|
||||
if not skip_vikunja:
|
||||
vikunja_title = get_vikunja_title_optional(task_id)
|
||||
if vikunja_title is None:
|
||||
token_set = bool(os.environ.get("VIKUNJA_TOKEN", ""))
|
||||
try:
|
||||
get_vikunja_token()
|
||||
token_set = True
|
||||
except click.ClickException:
|
||||
token_set = False
|
||||
if token_set:
|
||||
errors.append(
|
||||
_(
|
||||
@@ -233,17 +241,33 @@ def cli(
|
||||
else:
|
||||
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
|
||||
else:
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
# Defensive check: warn if the Vikunja task title already includes
|
||||
# the task ID prefix. The expected PR title is
|
||||
# f"{task_id}: {vikunja_title}" — if vikunja_title already starts
|
||||
# with "{task_id}:", the PR title will have a double prefix.
|
||||
if vikunja_title.startswith(f"{task_id}:"):
|
||||
errors.append(
|
||||
_(
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
expected=expected,
|
||||
title=pr_title,
|
||||
"Vikunja task title '{title}' starts with '{prefix}:'. "
|
||||
"The task title should NOT include the '{prefix}' prefix — "
|
||||
"it is automatically added to the PR title. "
|
||||
"Update the Vikunja task title to remove the prefix.",
|
||||
title=vikunja_title,
|
||||
prefix=task_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
errors.append(
|
||||
_(
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
expected=expected,
|
||||
title=pr_title,
|
||||
),
|
||||
)
|
||||
else:
|
||||
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
|
||||
|
||||
# 6. Branch behind master (skip if --skip-behind-check)
|
||||
if not skip_behind_check:
|
||||
|
||||
@@ -185,6 +185,7 @@ def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
|
||||
# Try common locations
|
||||
candidates = [
|
||||
root / "src" / "devx" / "translations.json",
|
||||
root / "src" / "grm" / "translations.json",
|
||||
]
|
||||
# Also search for any translations.json in src/
|
||||
for match in root.glob("src/*/translations.json"):
|
||||
|
||||
@@ -31,6 +31,7 @@ import requests
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -96,7 +97,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
return total
|
||||
|
||||
|
||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
|
||||
"""Determine the number of available runners.
|
||||
|
||||
Tries the Gitea API first, then falls back to env vars, then default.
|
||||
@@ -152,7 +153,10 @@ def main(
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
token = None
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
|
||||
@@ -21,6 +21,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
# Default to the current working directory (consuming repo's root)
|
||||
@@ -71,12 +72,30 @@ def extract_cli_commands(source_dir: Path) -> list[str]:
|
||||
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
||||
for match in re.finditer(r"@\w+\.command\b", content):
|
||||
# Check for explicit name="..." in the decorator arguments
|
||||
decorator_end = content.find(")", match.start())
|
||||
# Use a balanced paren search to find the end of the decorator
|
||||
# (handles nested parens like @cli.command(help=_("...")))
|
||||
depth = 0
|
||||
decorator_end = match.start()
|
||||
for i in range(match.start(), len(content)):
|
||||
if content[i] == "(":
|
||||
depth += 1
|
||||
elif content[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
decorator_end = i
|
||||
break
|
||||
decorator_text = content[match.start() : decorator_end + 1]
|
||||
name_match = re.search(r'["\']([^"\']+)["\']', decorator_text)
|
||||
# Look for explicit name="..." parameter (not help=, not other kwargs)
|
||||
name_match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_text)
|
||||
if name_match:
|
||||
commands.append(name_match.group(1))
|
||||
continue
|
||||
# Look for a positional string argument (e.g. @cli.command("my-cmd"))
|
||||
# but skip if the only strings are in help= or other keyword args
|
||||
positional_match = re.search(r'@\w+\.command\s*\(\s*["\']([^"\']+)["\']', decorator_text)
|
||||
if positional_match:
|
||||
commands.append(positional_match.group(1))
|
||||
continue
|
||||
# Find the next def statement after this decorator
|
||||
after = content[decorator_end:]
|
||||
def_match = re.search(r"def\s+(\w+)\s*\(", after)
|
||||
@@ -106,16 +125,38 @@ def check_module_documented(module: str, docs_content: str) -> bool:
|
||||
@click.command()
|
||||
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
|
||||
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
|
||||
@click.option(
|
||||
"--ci-scripts-dir",
|
||||
default=None,
|
||||
help=(
|
||||
"Path to CI scripts directory (default: auto-detect from src/ci/). "
|
||||
"Set to empty string to skip CI script checks."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--fail-on-missing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit with non-zero status if any documentation is missing.",
|
||||
)
|
||||
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
|
||||
def main(docs_dir: str | None, source_dir: str | None, ci_scripts_dir: str | None, fail_on_missing: bool) -> None:
|
||||
root = Path.cwd()
|
||||
docs_path = Path(docs_dir) if docs_dir else root / "docs"
|
||||
|
||||
# Read [tool.devx.doc_coverage] config from pyproject.toml
|
||||
devx_cfg = _load_pyproject_devx()
|
||||
doc_cov_cfg_raw: object = devx_cfg.get("doc_coverage", {}) if isinstance(devx_cfg, dict) else {}
|
||||
doc_cov_cfg: dict[str, object] = doc_cov_cfg_raw if isinstance(doc_cov_cfg_raw, dict) else {}
|
||||
|
||||
# CLI args override config; config overrides defaults
|
||||
if ci_scripts_dir is None and "ci_scripts_dir" in doc_cov_cfg:
|
||||
ci_scripts_dir = str(doc_cov_cfg["ci_scripts_dir"])
|
||||
if docs_dir is None and "docs_dir" in doc_cov_cfg:
|
||||
docs_dir = str(doc_cov_cfg["docs_dir"])
|
||||
docs_path = Path(docs_dir)
|
||||
if source_dir is None and "source_dir" in doc_cov_cfg:
|
||||
source_dir = str(doc_cov_cfg["source_dir"])
|
||||
|
||||
# Auto-detect source directory
|
||||
if source_dir:
|
||||
src_path = Path(source_dir)
|
||||
@@ -166,13 +207,27 @@ def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) ->
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# Check CI scripts in ci-cd-workflow.md
|
||||
# Auto-detect CI scripts from ci/ subdirectory
|
||||
# Auto-detect CI scripts from ci/ subdirectory, or use explicit config
|
||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
|
||||
if ci_dir.exists():
|
||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
||||
if ci_scripts_dir is not None:
|
||||
# Explicit config — empty string means skip CI script checks
|
||||
if ci_scripts_dir == "":
|
||||
detected_scripts = []
|
||||
else:
|
||||
ci_dir = Path(ci_scripts_dir)
|
||||
if ci_dir.exists():
|
||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
||||
else:
|
||||
detected_scripts = []
|
||||
else:
|
||||
detected_scripts = REQUIRED_SCRIPTS
|
||||
# Auto-detect from src_path/ci/
|
||||
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
|
||||
if ci_dir.exists():
|
||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
||||
else:
|
||||
# No ci/ directory found — skip CI script checks rather than falling back
|
||||
# to REQUIRED_SCRIPTS (which is devx-specific)
|
||||
detected_scripts = []
|
||||
total += len(detected_scripts)
|
||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||
for script in detected_scripts:
|
||||
|
||||
@@ -17,7 +17,7 @@ Usage::
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
CI_GITEA_TOKEN API token with repo access.
|
||||
CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
@@ -41,6 +41,7 @@ from devx.i18n import _
|
||||
from devx.molecule.molecule_ci_guard import (
|
||||
poll_for_other_failures,
|
||||
)
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
@@ -50,7 +51,10 @@ POLL_INTERVAL = 10
|
||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
token = None
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "integration-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
|
||||
@@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
|
||||
for issue creation — tea must be installed and configured.
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
--repo <owner/repo> \
|
||||
--run-id <run_id> \
|
||||
--workflow <workflow_name> \
|
||||
@@ -14,14 +14,13 @@ Usage:
|
||||
--auto-login
|
||||
|
||||
With ``--auto-login``, the script configures the tea CLI login profile
|
||||
from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
|
||||
from the CI API token and ``DEVX_GITEA_API_URL`` before creating the issue,
|
||||
eliminating the need for a separate ``tea login add`` step in the workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -29,6 +28,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -74,9 +74,10 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.",
|
||||
)
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
get_ci_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
if auto_login:
|
||||
configure_tea_login()
|
||||
|
||||
@@ -5,7 +5,6 @@ Usage:
|
||||
VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
@@ -17,6 +16,7 @@ from devx.ci._shared import extract_task_id as _extract_task_id
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_vikunja_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -127,9 +127,10 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
|
||||
commit_sha = _get_git_commit_sha()
|
||||
if not commit_msg:
|
||||
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_vikunja_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) from None
|
||||
|
||||
task_id = extract_task_id(commit_msg)
|
||||
if not task_id:
|
||||
|
||||
@@ -17,12 +17,11 @@ Checks performed:
|
||||
8. Commit conventions — conventional commit format on branch commits
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
CI_GITEA_API_TOKEN=<token> [REVIEWER_GITEA_API_TOKEN=<token>] python3 -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -34,6 +33,7 @@ from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token, get_reviewer_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -636,9 +636,10 @@ def main(
|
||||
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
|
||||
With --event: posts a manual review (skips automated checks).
|
||||
"""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_reviewer_token() if (event and event.upper() == "APPROVE") else get_ci_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
is converted to a packages URL). Uses ``twine upload
|
||||
--repository-url <url> -u <token> -p <token>`` with the
|
||||
``CI_GITEA_TOKEN`` as both username and password.
|
||||
CI API token as both username and password.
|
||||
2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard
|
||||
``twine upload -u __token__ -p <token>`` flow.
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created.
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
CI_GITEA_API_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -31,6 +31,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -253,9 +254,10 @@ def main(
|
||||
|
||||
if not tag:
|
||||
raise click.ClickException(_("Tag is required (or use --from-tag)."))
|
||||
gitea_token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
gitea_token = get_ci_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
pypi_token = os.environ.get("PYPI_TOKEN", "")
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ def push_to_badges_branch(badges_dir: str) -> str:
|
||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
|
||||
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
|
||||
_run(["git", "rm", "-rf", "."]) # nosec B607
|
||||
# Remove untracked files/dirs left behind (e.g. .badges/ from generate_badges)
|
||||
_run(["git", "clean", "-fdx", "-e", ".git"]) # nosec B607
|
||||
# Remove untracked files/dirs left behind, but preserve .badges/ for copy below
|
||||
_run(["git", "clean", "-fdx", "-e", ".git", "-e", badges_dir]) # nosec B607
|
||||
|
||||
# Copy badge files to root
|
||||
for svg in Path(badges_dir).glob("*.svg"):
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record the deployed git tag for a given environment.
|
||||
|
||||
Writes the tag to a Gitea repository variable so it can be queried
|
||||
later via the Gitea API or ``devx.ci.get_deployed_tag``.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.ci.record_deployed_tag --env production --tag v0.28.1
|
||||
python -m devx.ci.record_deployed_tag --env staging --tag master-abc1234
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--env",
|
||||
"env_name",
|
||||
type=click.Choice(["staging", "production"]),
|
||||
required=True,
|
||||
)
|
||||
@click.option("--tag", required=True, help=_("Git tag or ref that was deployed"))
|
||||
def main(env_name: str, tag: str) -> None:
|
||||
"""Record the deployed tag for the given environment."""
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException as exc:
|
||||
click.echo(f"Error: {exc.message}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
var_name = f"{env_name.upper()}_DEPLOY_TAG"
|
||||
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
|
||||
client.set_repo_variable(var_name, tag)
|
||||
click.echo(f"Recorded {var_name} = {tag}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI
|
||||
checkouts don't fetch tags) and ensures tag/version/commit alignment.
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
python3 -m devx.ci.release --verify # Check tag/version/release alignment
|
||||
"""
|
||||
|
||||
|
||||
+36
-14
@@ -21,7 +21,7 @@ Link transformations:
|
||||
- Anchor-only links (``#section``) are preserved
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
|
||||
CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,14 +31,16 @@ import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -49,6 +51,23 @@ MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||||
|
||||
|
||||
def wiki_filename(page_title: str) -> str:
|
||||
"""Convert a wiki page title to its Gitea wiki filename.
|
||||
|
||||
Gitea uses a "dash marker" (``.-``) suffix to distinguish literal dashes
|
||||
from space-to-dash conversions. See Gitea's ``services/wiki/wiki_path.go``.
|
||||
|
||||
- "Architecture" (no dashes) → ``Architecture.md``
|
||||
- "Getting-Started" (has dashes) → ``Getting-Started.-.md``
|
||||
- "Home" (no dashes) → ``Home.md``
|
||||
"""
|
||||
name = page_title.replace(" ", "-")
|
||||
if "-" in name:
|
||||
name += ".-"
|
||||
name += ".md"
|
||||
return quote(name, safe="")
|
||||
|
||||
|
||||
def load_mapping() -> dict[str, str]:
|
||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
||||
with open(MAPPING_FILE, encoding="utf-8") as f:
|
||||
@@ -170,16 +189,15 @@ def sync_files(
|
||||
# Transform links
|
||||
transformed = transform_links(content)
|
||||
|
||||
# Wiki filename: use the page title with spaces → underscores
|
||||
# Gitea wiki uses the page title as filename (spaces become dashes)
|
||||
wiki_filename = page_title.replace(" ", "-") + ".md"
|
||||
expected_files.add(wiki_filename)
|
||||
# Wiki filename: Gitea uses a dash-marker convention for titles with dashes
|
||||
fname = wiki_filename(page_title)
|
||||
expected_files.add(fname)
|
||||
|
||||
if not dry_run:
|
||||
dest = wiki_dir / wiki_filename
|
||||
dest = wiki_dir / fname
|
||||
dest.write_text(transformed, encoding="utf-8")
|
||||
synced += 1
|
||||
click.echo(_(" Synced: {title} → {file}", title=page_title, file=wiki_filename))
|
||||
click.echo(_(" Synced: {title} → {file}", title=page_title, file=fname))
|
||||
|
||||
# Prune stale pages (in wiki but not in mapping)
|
||||
pruned = 0
|
||||
@@ -234,7 +252,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
||||
|
||||
# Push
|
||||
result = subprocess.run( # nosec
|
||||
["git", "push", wiki_url, "HEAD:master"],
|
||||
["git", "push", "--force", wiki_url, "HEAD:master"],
|
||||
cwd=wiki_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -257,9 +275,10 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool) -> None:
|
||||
"""Sync documentation to the Gitea wiki via Git."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
if repo is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
@@ -309,6 +328,9 @@ def main(dry_run: bool, repo: str | None, verify: bool) -> None:
|
||||
|
||||
# Verification
|
||||
if verify and not dry_run:
|
||||
if pushed:
|
||||
click.echo(_("Waiting 5s for Gitea to process pushed commits..."))
|
||||
time.sleep(5)
|
||||
click.echo(_("\nVerifying wiki pages..."))
|
||||
# Re-clone to verify
|
||||
verify_dir = Path(tmpdir) / "verify"
|
||||
@@ -317,8 +339,8 @@ def main(dry_run: bool, repo: str | None, verify: bool) -> None:
|
||||
raise click.ClickException(_("Wiki verification failed — could not clone wiki"))
|
||||
failures = 0
|
||||
for _file_path, page_title in sorted(mapping.items()):
|
||||
wiki_filename = page_title.replace(" ", "-") + ".md"
|
||||
if (verify_dir / wiki_filename).exists():
|
||||
fname = wiki_filename(page_title)
|
||||
if (verify_dir / fname).exists():
|
||||
click.echo(_(" OK: {title}", title=page_title))
|
||||
else:
|
||||
click.echo(_(" FAIL: {title} — page not found in wiki!", title=page_title))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve and validate the git tag to deploy.
|
||||
|
||||
Shared between staging and production deployments. Ensures a concrete
|
||||
git tag is used — never a moving branch ref — so deployments are
|
||||
reproducible and rollback-friendly.
|
||||
|
||||
Usage in workflows::
|
||||
|
||||
# Production (tag required)
|
||||
python -m devx.ci.validate_deploy_ref --tag "$TAG" --github-output
|
||||
|
||||
# Staging force-deploy (tag required)
|
||||
python -m devx.ci.validate_deploy_ref --tag "$TAG" --github-output
|
||||
|
||||
# Staging PR-triggered (PR SHA is already concrete, no tag needed)
|
||||
python -m devx.ci.validate_deploy_ref --allow-empty --github-output
|
||||
|
||||
Writes ``deploy-ref=<tag>`` to ``$GITHUB_OUTPUT`` when ``--github-output``
|
||||
is passed, otherwise prints the ref to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--tag", default="", help=_("Git tag to deploy (e.g. v0.28.1)."))
|
||||
@click.option(
|
||||
"--allow-empty",
|
||||
is_flag=True,
|
||||
help=_("Allow empty tag (PR mode where SHA is concrete)."),
|
||||
)
|
||||
@click.option(
|
||||
"--github-output",
|
||||
is_flag=True,
|
||||
help=_("Write deploy-ref to $GITHUB_OUTPUT file."),
|
||||
)
|
||||
def main(tag: str, allow_empty: bool, github_output: bool) -> None:
|
||||
"""Resolve and validate the deploy ref, exiting non-zero on failure."""
|
||||
if not tag:
|
||||
if not allow_empty:
|
||||
click.echo(
|
||||
"::error::No tag specified. Deployments require a concrete git tag "
|
||||
"(e.g. v0.28.1). Use --allow-empty only for PR-triggered staging deploys "
|
||||
"where the checkout SHA is already concrete.",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
ref = ""
|
||||
click.echo("No tag specified — using checkout ref (PR mode).")
|
||||
else:
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(f"::error::Tag '{tag}' does not exist in the repository.", err=True)
|
||||
sys.exit(1)
|
||||
ref = tag
|
||||
commit = result.stdout.strip()[:8]
|
||||
click.echo(f"Deploying tag: {tag} (commit {commit})")
|
||||
|
||||
if github_output:
|
||||
github_output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if not github_output_path:
|
||||
click.echo("::error::GITHUB_OUTPUT environment variable not set.", err=True)
|
||||
sys.exit(1)
|
||||
with open(github_output_path, "a") as f:
|
||||
f.write(f"deploy-ref={ref}\n")
|
||||
else:
|
||||
click.echo(ref)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -40,7 +40,6 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
@@ -49,6 +48,7 @@ import click
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
@@ -56,10 +56,10 @@ class TeaCLIError(Exception):
|
||||
|
||||
|
||||
def configure_tea_login(login_name: str = "devx") -> None:
|
||||
"""Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL.
|
||||
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or CI_GITEA_TOKEN is not set.
|
||||
Skips silently if tea is not installed or no token is set.
|
||||
|
||||
Used by CI scripts (publish, notify_failure) that need tea login but
|
||||
run in containerized environments where ``make setup`` was not called.
|
||||
@@ -69,8 +69,9 @@ def configure_tea_login(login_name: str = "devx") -> None:
|
||||
click.echo(_("tea not installed — skipping login configuration."))
|
||||
return
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
|
||||
return
|
||||
|
||||
|
||||
+56
-12
@@ -39,6 +39,9 @@
|
||||
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
|
||||
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
|
||||
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
|
||||
# DEVX_DOC_COVERAGE_STRICT — fail on missing docs (default: 0)
|
||||
# DEVX_DOC_VERSIONS_PKG — package name for version ref checks (default: auto)
|
||||
# DEVX_VALE_LEVEL — vale alert threshold (default: warning)
|
||||
|
||||
DEVX_PYTHON ?= python3
|
||||
DEVX_PR_BASE ?= master
|
||||
@@ -52,15 +55,18 @@ DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||
DEVX_DOCKERFILE_PATHS ?= docker
|
||||
DEVX_VALE_LEVEL ?= warning
|
||||
|
||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
|
||||
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
|
||||
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
_TOKEN="$$CI_GITEA_API_TOKEN"; \
|
||||
[ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
|
||||
[ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
|
||||
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
if [ -n "$$_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
# ── Virtual environment management ────────────────────────────────────────────
|
||||
@@ -192,12 +198,14 @@ devx-pr-rebase:
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||
# private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env.
|
||||
# private packages. In CI, CI_GITEA_API_TOKEN is set as a secret. Locally, DEVELOPER_GITEA_API_TOKEN or CI_GITEA_TOKEN can be used.
|
||||
devx-configure-gitea-pypi:
|
||||
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
|
||||
@if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
_TOKEN="$$CI_GITEA_API_TOKEN"; \
|
||||
[ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
|
||||
[ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
if [ -z "$$_TOKEN" ]; then echo "[configure-gitea-pypi] Gitea API token not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (token present)."
|
||||
|
||||
# Create .env from .env.example if it doesn't exist
|
||||
devx-env:
|
||||
@@ -264,7 +272,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
|
||||
|
||||
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
|
||||
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
|
||||
# Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
|
||||
# Requires: CI_GITEA_API_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
|
||||
devx-notify-failure:
|
||||
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
|
||||
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
@@ -304,7 +312,7 @@ devx-test-unit:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
|
||||
|
||||
devx-pytest-cov:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
# ── Quality checks ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -328,10 +336,46 @@ devx-check-docs:
|
||||
devx-check-doc-versions:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root .
|
||||
|
||||
# Run Vale prose linter on docs and README
|
||||
# Documentation coverage — checks that all modules/scripts/CLI commands
|
||||
# are documented. Fails if any are missing when DEVX_DOC_COVERAGE_STRICT=1.
|
||||
devx-doc-coverage:
|
||||
@$(DEVX_PYTHON) -m devx.ci.doc_coverage $(if $(filter 1,$(DEVX_DOC_COVERAGE_STRICT)),--fail-on-missing)
|
||||
|
||||
# All-in-one documentation gate: coverage + stale refs + structural lint +
|
||||
# version refs + prose lint. Use in CI and pre-commit as a single step
|
||||
# instead of 5+ separate steps.
|
||||
#
|
||||
# Configuration via environment variables (set in Makefile before include
|
||||
# or in CI env):
|
||||
# DEVX_DOC_COVERAGE_STRICT=1 — fail on missing docs (recommended)
|
||||
# DEVX_DOC_VERSIONS_PKG=<pkg> — enable version ref checks for a named package
|
||||
# DEVX_VALE_LEVEL=<level> — vale alert threshold (error, warning, suggestion)
|
||||
# default: warning (catches weasel words, unlabeled
|
||||
# code blocks, etc. — not just spelling errors)
|
||||
devx-docs-check: devx-doc-coverage devx-check-docs
|
||||
@$(DEVX_PYTHON) -m devx.ci.lint_docs --root .
|
||||
@if [ -n "$(DEVX_DOC_VERSIONS_PKG)" ]; then \
|
||||
$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . --package $(DEVX_DOC_VERSIONS_PKG); \
|
||||
elif $(DEVX_PYTHON) -c "import importlib.util,sys; sys.exit(0 if any(importlib.util.find_spec(p) for p in ['devx','grm','oblachno_infra']) else 1)" 2>/dev/null; then \
|
||||
$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . 2>/dev/null || true; \
|
||||
fi
|
||||
@export PATH="$$HOME/.local/bin:$$PATH" && \
|
||||
if ! command -v vale >/dev/null 2>&1; then \
|
||||
echo "[devx-docs-check] vale not installed — skipping prose lint (install with 'make install-tools')"; \
|
||||
else \
|
||||
vale sync >/dev/null 2>&1 || true; \
|
||||
vale --minAlertLevel=$(DEVX_VALE_LEVEL) docs/ AGENTS.md README.md; \
|
||||
fi
|
||||
|
||||
# Run Vale prose linter on docs and README (skips if vale not installed)
|
||||
# Legacy target — use devx-docs-check for the full documentation gate.
|
||||
devx-vale:
|
||||
@export PATH="$$HOME/.local/bin:$$PATH" && \
|
||||
vale --minAlertLevel=error docs/ AGENTS.md README.md
|
||||
if ! command -v vale >/dev/null 2>&1; then \
|
||||
echo "[devx-vale] vale not installed — skipping (install with 'make install-tools')"; \
|
||||
else \
|
||||
vale --minAlertLevel=error docs/ AGENTS.md README.md; \
|
||||
fi
|
||||
|
||||
# Verify test suite timing
|
||||
devx-check-test-speed:
|
||||
|
||||
@@ -30,6 +30,7 @@ import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -86,7 +87,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
return total
|
||||
|
||||
|
||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
|
||||
"""Determine the number of available runners.
|
||||
|
||||
Tries the Gitea API first, then falls back to env vars, then default.
|
||||
@@ -142,7 +143,10 @@ def main(
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
token = None
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
|
||||
@@ -22,7 +22,7 @@ Usage::
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
CI_GITEA_TOKEN API token with repo access.
|
||||
CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
@@ -45,6 +45,7 @@ import requests
|
||||
|
||||
from devx.config import REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
@@ -164,7 +165,10 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
|
||||
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException:
|
||||
token = None
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
|
||||
@@ -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)
|
||||
@@ -8,6 +8,8 @@ import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
|
||||
def arch_string() -> str:
|
||||
"""Return the architecture string used by release assets.
|
||||
@@ -45,8 +47,9 @@ def detect_pr_number() -> int | None:
|
||||
if branch == "HEAD":
|
||||
return None
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
return None
|
||||
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "")
|
||||
|
||||
@@ -34,8 +34,8 @@ The manifest file is a JSON list of dicts, each with:
|
||||
- ``context``: build context directory (optional, defaults to repo root)
|
||||
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
|
||||
|
||||
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
|
||||
environment variables, matching the existing CI workflow patterns.
|
||||
Registry authentication uses ``CI_GITEA_API_TOKEN`` (or legacy ``CI_GITEA_TOKEN``)
|
||||
and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workflow patterns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,6 +49,7 @@ from pathlib import Path
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -221,9 +222,12 @@ def push_image(
|
||||
|
||||
def _get_registry_creds() -> tuple[str, str]:
|
||||
"""Get registry credentials from environment variables."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
token = None
|
||||
username = os.environ.get("CI_GITEA_USERNAME", "")
|
||||
return username, token
|
||||
return username, token or ""
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -28,12 +28,11 @@ Usage::
|
||||
--keep 2 \\
|
||||
--dry-run
|
||||
|
||||
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
|
||||
Authentication uses ``CI_GITEA_API_TOKEN`` environment variable (or legacy ``CI_GITEA_TOKEN``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +41,7 @@ import requests
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
|
||||
def list_package_versions(
|
||||
@@ -187,9 +187,10 @@ def main(
|
||||
api_url: str | None,
|
||||
) -> None:
|
||||
"""Clean up old Docker image versions from a Gitea registry."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) from None
|
||||
if not owner:
|
||||
owner = REPO_OWNER
|
||||
if not owner:
|
||||
|
||||
@@ -7,8 +7,8 @@ ci-improvement, doc-improvement, workflow-improvement) are created
|
||||
idempotently via ``ensure_label``.
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
|
||||
DEVELOPER_GITEA_API_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
|
||||
DEVELOPER_GITEA_API_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,6 +23,7 @@ from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
|
||||
def _default_status_checks() -> list[str]:
|
||||
@@ -175,7 +176,10 @@ def configure_repo(
|
||||
)
|
||||
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
|
||||
"""Configure branch protection and repository settings via the Gitea API."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
|
||||
@@ -42,6 +42,7 @@ from devx.config import (
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token, get_vikunja_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -72,9 +73,10 @@ def get_vikunja_task_title(task_id: str) -> str:
|
||||
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
|
||||
try:
|
||||
token = get_vikunja_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) from None
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
|
||||
if not task:
|
||||
@@ -118,9 +120,10 @@ def create_pr(
|
||||
),
|
||||
)
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR."))
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) from None
|
||||
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
pr_title = f"{task_id}: {vikunja_title}"
|
||||
|
||||
@@ -17,14 +17,13 @@ and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_vikunja_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -39,9 +38,10 @@ load_dotenv()
|
||||
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
|
||||
def cli(title: str, description: str, project_id: int | None) -> None:
|
||||
"""Create a Vikunja task and print its identifier."""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment."))
|
||||
try:
|
||||
token = get_vikunja_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) from None
|
||||
|
||||
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ def detect_package_name(repo_root: Path) -> str | None:
|
||||
Looks for the first subdirectory under ``src/`` that contains
|
||||
an ``__init__.py`` file with ``__version__``.
|
||||
|
||||
Returns the package directory name (e.g., ``devx``) or ``None`` if
|
||||
no package is found.
|
||||
Returns the package directory name (e.g., ``devx``,
|
||||
``grm``) or ``None`` if no package is found.
|
||||
"""
|
||||
src_dir = repo_root / "src"
|
||||
if not src_dir.is_dir():
|
||||
|
||||
@@ -22,14 +22,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
@@ -48,9 +47,10 @@ def cli(
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Add one or more labels to a pull request (idempotent)."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
|
||||
@@ -25,14 +25,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
@@ -126,9 +125,10 @@ def cli(
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Fetch logs for failed CI jobs on a pull request."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
|
||||
@@ -32,6 +32,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
|
||||
@@ -41,9 +42,10 @@ def main(pr: int | None) -> None:
|
||||
"""Rebase a pull request's head branch onto master via Gitea API."""
|
||||
load_dotenv()
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it."))
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) from None
|
||||
|
||||
pr_num = pr or detect_pr_number()
|
||||
if not pr_num:
|
||||
|
||||
@@ -24,7 +24,6 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
|
||||
@@ -34,6 +33,7 @@ from dotenv import load_dotenv
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_developer_token
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
|
||||
load_dotenv()
|
||||
@@ -139,9 +139,10 @@ def cli(
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Check CI status for a pull request or commit."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
|
||||
@@ -20,7 +20,6 @@ Exit codes:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
@@ -29,6 +28,7 @@ from dotenv import load_dotenv
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_vikunja_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -55,8 +55,9 @@ def task_exists(task_id: str) -> bool:
|
||||
|
||||
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
try:
|
||||
token = get_vikunja_token()
|
||||
except click.ClickException:
|
||||
return False
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
|
||||
@@ -84,8 +85,9 @@ def validate(branch: str) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
try:
|
||||
get_vikunja_token()
|
||||
except click.ClickException:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
|
||||
|
||||
@@ -16,6 +16,8 @@ from pathlib import Path
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@@ -64,19 +66,20 @@ def _install_ansible_collections(bin_dir: str) -> None:
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
"""Configure tea CLI login from .env if CI_GITEA_TOKEN is set.
|
||||
"""Configure tea CLI login from .env if a Gitea token is set.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips if tea is not installed or CI_GITEA_TOKEN is not set.
|
||||
Skips if tea is not installed or no Gitea token is set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("tea: not installed — run 'make install-tools' to install it.")
|
||||
return
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.")
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
click.echo("tea: Gitea API token not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
@@ -24,6 +24,8 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
DEFAULT_VENV = ".venv"
|
||||
OPT_VENV = "/opt/venv"
|
||||
FALLBACK_TARGET = "setup-ci"
|
||||
@@ -67,7 +69,10 @@ def _install_in_image(
|
||||
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
|
||||
|
||||
env = os.environ.copy()
|
||||
token = env.get("CI_GITEA_TOKEN", "")
|
||||
try:
|
||||
token = get_developer_token()
|
||||
except click.ClickException:
|
||||
token = None
|
||||
if token:
|
||||
username = env.get("CI_GITEA_USERNAME", "emil")
|
||||
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
|
||||
|
||||
+859
-611
@@ -47,13 +47,13 @@
|
||||
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
},
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
|
||||
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"pl": "\nGotowe! Utworzono: {created}, Zaktualizowano: {updated}, Pominięto: {skipped}",
|
||||
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
"\nDone! Synced: {synced}, Pruned: {pruned}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nDone! Synced: {synced}, Pruned: {pruned}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
|
||||
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
@@ -71,6 +71,14 @@
|
||||
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
||||
},
|
||||
"\nFAIL: {n} stale version reference(s) found:": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nFAIL: {n} stale version reference(s) found:",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
|
||||
"bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
@@ -79,6 +87,14 @@
|
||||
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
},
|
||||
"\nFixed {n} stale version reference(s).": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nFixed {n} stale version reference(s).",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nGenerated {count} badges:": {
|
||||
"bg": "\nGenerated {count} badges:",
|
||||
"de": "\nGenerated {count} badges:",
|
||||
@@ -87,22 +103,6 @@
|
||||
"ru": "\nGenerated {count} badges:",
|
||||
"zh": "\nGenerated {count} badges:"
|
||||
},
|
||||
"\nIntegrity check FAILED ({count} issues):": {
|
||||
"bg": "\nIntegrity check FAILED ({count} issues):",
|
||||
"de": "\nIntegrity check FAILED ({count} issues):",
|
||||
"en": "\nIntegrity check FAILED ({count} issues):",
|
||||
"pl": "\nKontrola integralności NIEUDANA ({count} problemów):",
|
||||
"ru": "\nIntegrity check FAILED ({count} issues):",
|
||||
"zh": "\nIntegrity check FAILED ({count} issues):"
|
||||
},
|
||||
"\nIntegrity check passed — all {count} pages verified.": {
|
||||
"bg": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"de": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"en": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"pl": "\nKontrola integralności zakończona pomyślnie — wszystkie {count} stron zweryfikowane.",
|
||||
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"zh": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nKeeping {kept}, would delete {count}": {
|
||||
"bg": "\nKeeping {kept}, would delete {count}",
|
||||
"de": "\nKeeping {kept}, would delete {count}",
|
||||
@@ -127,6 +127,22 @@
|
||||
"ru": "\nMissing documentation:",
|
||||
"zh": "\nMissing documentation:"
|
||||
},
|
||||
"\nNo stale version references found.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nNo stale version references found.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nPASS: All version references are current.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nPASS: All version references are current.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nResult: {status}": {
|
||||
"bg": "\nResult: {status}",
|
||||
"de": "\nResult: {status}",
|
||||
@@ -151,13 +167,13 @@
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||
},
|
||||
"\nRunning full wiki integrity check...": {
|
||||
"bg": "\nRunning full wiki integrity check...",
|
||||
"de": "\nRunning full wiki integrity check...",
|
||||
"en": "\nRunning full wiki integrity check...",
|
||||
"pl": "\nUruchamianie pełnej kontroli integralności wiki...",
|
||||
"ru": "\nRunning full wiki integrity check...",
|
||||
"zh": "\nRunning full wiki integrity check..."
|
||||
"\nRun with --fix to auto-update version references.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nRun with --fix to auto-update version references.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nTag → Commit alignment:": {
|
||||
"bg": "\nTag → Commit alignment:",
|
||||
@@ -183,29 +199,21 @@
|
||||
"ru": "\nUser-facing changes ({count}):",
|
||||
"zh": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
|
||||
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"pl": "\nWeryfikacja NIEUDANA: {failures} strona(y) ma pustą lub niezgodną treść!",
|
||||
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
"\nVerification passed — all wiki pages exist.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nVerification passed — all wiki pages exist.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nVerification passed — all wiki pages have correct content.": {
|
||||
"bg": "\nVerification passed — all wiki pages have correct content.",
|
||||
"de": "\nVerification passed — all wiki pages have correct content.",
|
||||
"en": "\nVerification passed — all wiki pages have correct content.",
|
||||
"pl": "\nWeryfikacja zakończona pomyślnie — wszystkie strony wiki mają poprawną treść.",
|
||||
"ru": "\nVerification passed — all wiki pages have correct content.",
|
||||
"zh": "\nVerification passed — all wiki pages have correct content."
|
||||
},
|
||||
"\nVerifying wiki pages have content...": {
|
||||
"bg": "\nVerifying wiki pages have content...",
|
||||
"de": "\nVerifying wiki pages have content...",
|
||||
"en": "\nVerifying wiki pages have content...",
|
||||
"pl": "\nWeryfikowanie, czy strony wiki mają treść...",
|
||||
"ru": "\nVerifying wiki pages have content...",
|
||||
"zh": "\nVerifying wiki pages have content..."
|
||||
"\nVerifying wiki pages...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "\nVerifying wiki pages...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"\nWorkflow-only changes ({count}):": {
|
||||
"bg": "\nWorkflow-only changes ({count}):",
|
||||
@@ -351,6 +359,30 @@
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" - {count} standard labels verified": {
|
||||
"bg": " - {count} standard labels verified",
|
||||
"de": " - {count} standard labels verified",
|
||||
"en": " - {count} standard labels verified",
|
||||
"pl": " - {count} standard labels verified",
|
||||
"ru": " - {count} standard labels verified",
|
||||
"zh": " - {count} standard labels verified"
|
||||
},
|
||||
" -> {dir}": {
|
||||
"en": " -> {dir}",
|
||||
"bg": " -> {dir}",
|
||||
"de": " -> {dir}",
|
||||
"pl": " -> {dir}",
|
||||
"ru": " -> {dir}",
|
||||
"zh": " -> {dir}"
|
||||
},
|
||||
" ... and {n} more": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " ... and {n} more",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" Auto-fixed trailing whitespace in {n} files": {
|
||||
"bg": " Auto-fixed trailing whitespace in {n} files",
|
||||
"de": " Auto-fixed trailing whitespace in {n} files",
|
||||
@@ -391,14 +423,6 @@
|
||||
"ru": " Collecting version...",
|
||||
"zh": " Collecting version..."
|
||||
},
|
||||
" Created: {title}": {
|
||||
"bg": " Created: {title}",
|
||||
"de": " Created: {title}",
|
||||
"en": " Created: {title}",
|
||||
"pl": " Utworzono: {title}",
|
||||
"ru": " Created: {title}",
|
||||
"zh": " Created: {title}"
|
||||
},
|
||||
" Deleted: {version}": {
|
||||
"bg": " Deleted: {version}",
|
||||
"de": " Deleted: {version}",
|
||||
@@ -407,13 +431,13 @@
|
||||
"ru": " Deleted: {version}",
|
||||
"zh": " Deleted: {version}"
|
||||
},
|
||||
" FAIL: {title} — content mismatch or empty!": {
|
||||
"bg": " FAIL: {title} — content mismatch or empty!",
|
||||
"de": " FAIL: {title} — content mismatch or empty!",
|
||||
"en": " FAIL: {title} — content mismatch or empty!",
|
||||
"pl": " BŁĄD: {title} — treść niezgodna lub pusta!",
|
||||
"ru": " FAIL: {title} — content mismatch or empty!",
|
||||
"zh": " FAIL: {title} — content mismatch or empty!"
|
||||
" FAIL: {title} — page not found in wiki!": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " FAIL: {title} — page not found in wiki!",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" FAILED to delete: {version}": {
|
||||
"bg": " FAILED to delete: {version}",
|
||||
@@ -423,6 +447,14 @@
|
||||
"ru": " FAILED to delete: {version}",
|
||||
"zh": " FAILED to delete: {version}"
|
||||
},
|
||||
" Fixed {fixes} version ref(s) in {file}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " Fixed {fixes} version ref(s) in {file}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" Generated: {path}": {
|
||||
"bg": " Generated: {path}",
|
||||
"de": " Generated: {path}",
|
||||
@@ -479,13 +511,13 @@
|
||||
"ru": " OK: {script}",
|
||||
"zh": " OK: {script}"
|
||||
},
|
||||
" OK: {title} ({chars} chars)": {
|
||||
"bg": " OK: {title} ({chars} chars)",
|
||||
"de": " OK: {title} ({chars} chars)",
|
||||
"en": " OK: {title} ({chars} chars)",
|
||||
"pl": " OK: {title} ({chars} znaków)",
|
||||
"ru": " OK: {title} ({chars} chars)",
|
||||
"zh": " OK: {title} ({chars} chars)"
|
||||
" OK: {title}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " OK: {title}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" Package: {pkg}": {
|
||||
"bg": " Package: {pkg}",
|
||||
@@ -495,6 +527,14 @@
|
||||
"ru": " Package: {pkg}",
|
||||
"zh": " Package: {pkg}"
|
||||
},
|
||||
" Pruned: {file} (not in mapping)": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " Pruned: {file} (not in mapping)",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" Quality checks: {checks}": {
|
||||
"bg": " Quality checks: {checks}",
|
||||
"de": " Quality checks: {checks}",
|
||||
@@ -511,6 +551,22 @@
|
||||
"ru": " Repo root: {root}",
|
||||
"zh": " Repo root: {root}"
|
||||
},
|
||||
" Run 'make install-checkmake' to install the Makefile linter.": {
|
||||
"en": " Run 'make install-checkmake' to install the Makefile linter.",
|
||||
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
|
||||
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
|
||||
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
|
||||
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
|
||||
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
|
||||
},
|
||||
" Synced: {title} → {file}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " Synced: {title} → {file}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" Test paths: {testpaths}": {
|
||||
"bg": " Test paths: {testpaths}",
|
||||
"de": " Test paths: {testpaths}",
|
||||
@@ -519,13 +575,21 @@
|
||||
"ru": " Test paths: {testpaths}",
|
||||
"zh": " Test paths: {testpaths}"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"bg": " Updated: {title}",
|
||||
"de": " Updated: {title}",
|
||||
"en": " Updated: {title}",
|
||||
"pl": " Zaktualizowano: {title}",
|
||||
"ru": " Updated: {title}",
|
||||
"zh": " Updated: {title}"
|
||||
" WARN: Mapped file {file} is empty, skipping": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " WARN: Mapped file {file} is empty, skipping",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" WARN: Mapped file {file} not found, skipping": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " WARN: Mapped file {file} not found, skipping",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
|
||||
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||
@@ -615,6 +679,22 @@
|
||||
"ru": " {name}: {label}={message} ({color})",
|
||||
"zh": " {name}: {label}={message} ({color})"
|
||||
},
|
||||
" {n} long lines found (warnings only)": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " {n} long lines found (warnings only)",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" {n} orphan docs found (warnings only)": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": " {n} orphan docs found (warnings only)",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
" {n} stale docs found (warnings only)": {
|
||||
"bg": " {n} stale docs found (warnings only)",
|
||||
"de": " {n} stale docs found (warnings only)",
|
||||
@@ -623,6 +703,14 @@
|
||||
"ru": " {n} stale docs found (warnings only)",
|
||||
"zh": " {n} stale docs found (warnings only)"
|
||||
},
|
||||
" {tool}: found at {path}": {
|
||||
"en": " {tool}: found at {path}",
|
||||
"bg": " {tool}: намерен на {path}",
|
||||
"de": " {tool}: gefunden unter {path}",
|
||||
"pl": " {tool}: znaleziono w {path}",
|
||||
"ru": " {tool}: найден в {path}",
|
||||
"zh": " {tool}: 在 {path} 找到"
|
||||
},
|
||||
" {version} (created: {created})": {
|
||||
"bg": " {version} (created: {created})",
|
||||
"de": " {version} (created: {created})",
|
||||
@@ -727,6 +815,22 @@
|
||||
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
|
||||
},
|
||||
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||
},
|
||||
"Automated CI commit (badge) — skipping post-merge jobs.": {
|
||||
"bg": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"de": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"en": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"pl": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"ru": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"zh": "Automated CI commit (badge) — skipping post-merge jobs."
|
||||
},
|
||||
"Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
|
||||
"bg": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
"de": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
@@ -775,6 +879,22 @@
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
||||
},
|
||||
"Branch is already up-to-date with origin/master.": {
|
||||
"bg": "Branch is already up-to-date with origin/master.",
|
||||
"de": "Branch is already up-to-date with origin/master.",
|
||||
"en": "Branch is already up-to-date with origin/master.",
|
||||
"pl": "Branch is already up-to-date with origin/master.",
|
||||
"ru": "Branch is already up-to-date with origin/master.",
|
||||
"zh": "Branch is already up-to-date with origin/master."
|
||||
},
|
||||
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
|
||||
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
|
||||
},
|
||||
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
|
||||
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
@@ -783,6 +903,14 @@
|
||||
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
|
||||
},
|
||||
"Branch is {count} commit(s) behind master. Rebasing...": {
|
||||
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"de": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"en": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
|
||||
},
|
||||
"Branch name (e.g., DEVX-256-fix-foo)": {
|
||||
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"de": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
@@ -831,6 +959,14 @@
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
},
|
||||
"Gitea API token not set. Set one of: {names}": {
|
||||
"bg": "Gitea API token not set. Set one of: {names}",
|
||||
"de": "Gitea API token not set. Set one of: {names}",
|
||||
"en": "Gitea API token not set. Set one of: {names}",
|
||||
"pl": "Gitea API token not set. Set one of: {names}",
|
||||
"ru": "Gitea API token not set. Set one of: {names}",
|
||||
"zh": "Gitea API token not set. Set one of: {names}"
|
||||
},
|
||||
"CI_GITEA_TOKEN environment variable required": {
|
||||
"bg": "CI_GITEA_TOKEN environment variable required",
|
||||
"de": "CI_GITEA_TOKEN environment variable required",
|
||||
@@ -847,6 +983,14 @@
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
|
||||
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
|
||||
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||
@@ -863,6 +1007,22 @@
|
||||
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
|
||||
},
|
||||
"Cannot read __version__ from src/{pkg}/__init__.py — skipping.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Cannot read __version__ from src/{pkg}/__init__.py — skipping.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Cannot rebase: not on a branch (detached HEAD).": {
|
||||
"bg": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"de": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"en": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"pl": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"ru": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"zh": "Cannot rebase: not on a branch (detached HEAD)."
|
||||
},
|
||||
"Checking CLI command documentation...": {
|
||||
"bg": "Checking CLI command documentation...",
|
||||
"de": "Checking CLI command documentation...",
|
||||
@@ -871,6 +1031,14 @@
|
||||
"ru": "Checking CLI command documentation...",
|
||||
"zh": "Checking CLI command documentation..."
|
||||
},
|
||||
"Checking code block languages...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Checking code block languages...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Checking docs structure...": {
|
||||
"bg": "Checking docs structure...",
|
||||
"de": "Checking docs structure...",
|
||||
@@ -895,6 +1063,14 @@
|
||||
"ru": "Checking for TODO/FIXME markers...",
|
||||
"zh": "Checking for TODO/FIXME markers..."
|
||||
},
|
||||
"Checking for orphan docs...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Checking for orphan docs...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Checking for stale docs...": {
|
||||
"bg": "Checking for stale docs...",
|
||||
"de": "Checking for stale docs...",
|
||||
@@ -919,6 +1095,22 @@
|
||||
"ru": "Checking internal links...",
|
||||
"zh": "Checking internal links..."
|
||||
},
|
||||
"Checking line length...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Checking line length...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Checking max heading depth...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Checking max heading depth...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Checking required files...": {
|
||||
"bg": "Checking required files...",
|
||||
"de": "Checking required files...",
|
||||
@@ -927,6 +1119,14 @@
|
||||
"ru": "Checking required files...",
|
||||
"zh": "Checking required files..."
|
||||
},
|
||||
"Checking single H1 per file...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Checking single H1 per file...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
@@ -943,6 +1143,30 @@
|
||||
"ru": "Checking trailing whitespace...",
|
||||
"zh": "Checking trailing whitespace..."
|
||||
},
|
||||
"Checking version references for {pkg} (current: v{version})": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Checking version references for {pkg} (current: v{version})",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Cloned existing wiki.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Cloned existing wiki.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Cloning wiki repo...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Cloning wiki repo...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
@@ -967,6 +1191,14 @@
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
},
|
||||
"Committing and pushing...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Committing and pushing...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Comparing {base}..{head} ({count} files changed)": {
|
||||
"bg": "Comparing {base}..{head} ({count} files changed)",
|
||||
"de": "Comparing {base}..{head} ({count} files changed)",
|
||||
@@ -1015,6 +1247,14 @@
|
||||
"ru": "Configuring tea login '{name}' for {url}...",
|
||||
"zh": "Configuring tea login '{name}' for {url}..."
|
||||
},
|
||||
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
|
||||
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
|
||||
},
|
||||
"Could not detect current branch: {error}": {
|
||||
"bg": "Не може да се определи текущия клон: {error}",
|
||||
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||
@@ -1031,6 +1271,14 @@
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
},
|
||||
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
|
||||
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"bg": "Could not extract conventional commit message from PR commits.",
|
||||
"de": "Could not extract conventional commit message from PR commits.",
|
||||
@@ -1119,6 +1367,14 @@
|
||||
"ru": "Dependencies must have documentation comments.",
|
||||
"zh": "Dependencies must have documentation comments."
|
||||
},
|
||||
"Directory to scan (default: tests/integration). Can be repeated.": {
|
||||
"en": "Directory to scan (default: tests/integration). Can be repeated.",
|
||||
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
|
||||
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
|
||||
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
|
||||
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
|
||||
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
|
||||
},
|
||||
"Docker daemon already running": {
|
||||
"bg": "Докер демонът вече работи",
|
||||
"de": "Docker-Daemon läuft bereits",
|
||||
@@ -1207,6 +1463,22 @@
|
||||
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||
},
|
||||
"Ensuring standard labels...": {
|
||||
"bg": "Ensuring standard labels...",
|
||||
"de": "Ensuring standard labels...",
|
||||
"en": "Ensuring standard labels...",
|
||||
"pl": "Ensuring standard labels...",
|
||||
"ru": "Ensuring standard labels...",
|
||||
"zh": "Ensuring standard labels..."
|
||||
},
|
||||
"FAIL: Could not clone wiki for verification.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "FAIL: Could not clone wiki for verification.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"FAIL: {n} documentation issues found:": {
|
||||
"bg": "FAIL: {n} documentation issues found:",
|
||||
"de": "FAIL: {n} documentation issues found:",
|
||||
@@ -1263,6 +1535,30 @@
|
||||
"ru": "Failed to list versions for {name}: {error}",
|
||||
"zh": "Failed to list versions for {name}: {error}"
|
||||
},
|
||||
"Failed to push release commit after 3 attempts. Manual intervention required.": {
|
||||
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
|
||||
},
|
||||
"Failed to start ssh-agent: {error}": {
|
||||
"en": "Failed to start ssh-agent: {error}",
|
||||
"bg": "Неуспешно стартиране на ssh-agent: {error}",
|
||||
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
|
||||
"pl": "Nie udało się uruchomić ssh-agent: {error}",
|
||||
"ru": "Не удалось запустить ssh-agent: {error}",
|
||||
"zh": "启动 ssh-agent 失败: {error}"
|
||||
},
|
||||
"Fetch failed: {error}": {
|
||||
"bg": "Fetch failed: {error}",
|
||||
"de": "Fetch failed: {error}",
|
||||
"en": "Fetch failed: {error}",
|
||||
"pl": "Fetch failed: {error}",
|
||||
"ru": "Fetch failed: {error}",
|
||||
"zh": "Fetch failed: {error}"
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
@@ -1271,13 +1567,29 @@
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
},
|
||||
"Found {count} existing wiki pages.": {
|
||||
"bg": "Found {count} existing wiki pages.",
|
||||
"de": "Found {count} existing wiki pages.",
|
||||
"en": "Found {count} existing wiki pages.",
|
||||
"pl": "Znaleziono {count} istniejących stron wiki.",
|
||||
"ru": "Found {count} existing wiki pages.",
|
||||
"zh": "Found {count} existing wiki pages."
|
||||
"Fetching origin/master...": {
|
||||
"bg": "Fetching origin/master...",
|
||||
"de": "Fetching origin/master...",
|
||||
"en": "Fetching origin/master...",
|
||||
"pl": "Fetching origin/master...",
|
||||
"ru": "Fetching origin/master...",
|
||||
"zh": "Fetching origin/master..."
|
||||
},
|
||||
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
|
||||
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
|
||||
},
|
||||
"Force-pushing...": {
|
||||
"bg": "Force-pushing...",
|
||||
"de": "Force-pushing...",
|
||||
"en": "Force-pushing...",
|
||||
"pl": "Force-pushing...",
|
||||
"ru": "Force-pushing...",
|
||||
"zh": "Force-pushing..."
|
||||
},
|
||||
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
|
||||
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
@@ -1295,6 +1607,14 @@
|
||||
"ru": "Found {count} stale documentation reference(s)",
|
||||
"zh": "Found {count} stale documentation reference(s)"
|
||||
},
|
||||
"Found {count} unsafe identity check(s) in integration tests.": {
|
||||
"en": "Found {count} unsafe identity check(s) in integration tests.",
|
||||
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
|
||||
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
|
||||
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
|
||||
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
|
||||
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
|
||||
},
|
||||
"Found {count} version(s):": {
|
||||
"bg": "Found {count} version(s):",
|
||||
"de": "Found {count} version(s):",
|
||||
@@ -1519,6 +1839,14 @@
|
||||
"ru": "Linting documentation in {root}...",
|
||||
"zh": "Linting documentation in {root}..."
|
||||
},
|
||||
"Login to {registry} failed: {error}": {
|
||||
"en": "Login to {registry} failed: {error}",
|
||||
"bg": "Влизането в {registry} не успя: {error}",
|
||||
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
|
||||
"pl": "Logowanie do {registry} nie powiodło się: {error}",
|
||||
"ru": "Ошибка входа в {registry}: {error}",
|
||||
"zh": "登录 {registry} 失败: {error}"
|
||||
},
|
||||
"Manifest file not found: {path}": {
|
||||
"bg": "Manifest file not found: {path}",
|
||||
"de": "Manifest file not found: {path}",
|
||||
@@ -1535,22 +1863,6 @@
|
||||
"ru": "Manifest must be a JSON list",
|
||||
"zh": "Manifest must be a JSON list"
|
||||
},
|
||||
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
|
||||
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"pl": "Mapowany plik {file} jest pusty. Zaktualizuj treść lub usuń z mapping.json.",
|
||||
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
|
||||
},
|
||||
"Mapped file {file} not found. Update mapping.json or create the file.": {
|
||||
"bg": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"de": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"pl": "Mapowany plik {file} nie znaleziony. Zaktualizuj mapping.json lub utwórz plik.",
|
||||
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
|
||||
},
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
|
||||
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
|
||||
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
|
||||
@@ -1631,6 +1943,14 @@
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
},
|
||||
"No Python package found under src/ — skipping version check.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "No Python package found under src/ — skipping version check.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"No badge SVG files generated": {
|
||||
"bg": "No badge SVG files generated",
|
||||
"de": "No badge SVG files generated",
|
||||
@@ -1647,6 +1967,14 @@
|
||||
"ru": "No badge URLs found to update — README already up to date",
|
||||
"zh": "No badge URLs found to update — README already up to date"
|
||||
},
|
||||
"No badge changes — skipping commit": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "No badge changes — skipping commit",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"No changes between {base} and {head}.": {
|
||||
"bg": "No changes between {base} and {head}.",
|
||||
"de": "No changes between {base} and {head}.",
|
||||
@@ -1655,6 +1983,14 @@
|
||||
"ru": "No changes between {base} and {head}.",
|
||||
"zh": "No changes between {base} and {head}."
|
||||
},
|
||||
"No changes to sync — wiki is up to date.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "No changes to sync — wiki is up to date.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
@@ -1687,6 +2023,14 @@
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
},
|
||||
"No push needed (no changes or push failed).": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "No push needed (no changes or push failed).",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"No staged changes — version and changelog already up to date.": {
|
||||
"bg": "No staged changes — version and changelog already up to date.",
|
||||
"de": "No staged changes — version and changelog already up to date.",
|
||||
@@ -1767,6 +2111,14 @@
|
||||
"ru": "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)": {
|
||||
"bg": "Only check staged files (for pre-commit)",
|
||||
"de": "Only check staged files (for pre-commit)",
|
||||
@@ -1871,6 +2223,14 @@
|
||||
"ru": "PASSED: {pair}",
|
||||
"zh": "PASSED: {pair}"
|
||||
},
|
||||
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
||||
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
|
||||
},
|
||||
"PR already exists: #{index} — {url}": {
|
||||
"bg": "PR вече съществува: #{index} — {url}",
|
||||
"de": "PR existiert bereits: #{index} — {url}",
|
||||
@@ -2047,6 +2407,14 @@
|
||||
"ru": "Publishing release {tag}...",
|
||||
"zh": "Publishing release {tag}..."
|
||||
},
|
||||
"Push attempt {n}/3 failed: {err}": {
|
||||
"bg": "Push attempt {n}/3 failed: {err}",
|
||||
"de": "Push attempt {n}/3 failed: {err}",
|
||||
"en": "Push attempt {n}/3 failed: {err}",
|
||||
"pl": "Push attempt {n}/3 failed: {err}",
|
||||
"ru": "Push attempt {n}/3 failed: {err}",
|
||||
"zh": "Push attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Push failed for {tag}: {error}": {
|
||||
"bg": "Push failed for {tag}: {error}",
|
||||
"de": "Push failed for {tag}: {error}",
|
||||
@@ -2055,6 +2423,14 @@
|
||||
"ru": "Push failed for {tag}: {error}",
|
||||
"zh": "Push failed for {tag}: {error}"
|
||||
},
|
||||
"Push failed: {error}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Push failed: {error}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Pushed README update with badge SHA {sha}": {
|
||||
"bg": "Pushed README update with badge SHA {sha}",
|
||||
"de": "Pushed README update with badge SHA {sha}",
|
||||
@@ -2071,6 +2447,14 @@
|
||||
"ru": "Pushed release commit to master.",
|
||||
"zh": "Pushed release commit to master."
|
||||
},
|
||||
"Pushed {branch} to origin.": {
|
||||
"bg": "Pushed {branch} to origin.",
|
||||
"de": "Pushed {branch} to origin.",
|
||||
"en": "Pushed {branch} to origin.",
|
||||
"pl": "Pushed {branch} to origin.",
|
||||
"ru": "Pushed {branch} to origin.",
|
||||
"zh": "Pushed {branch} to origin."
|
||||
},
|
||||
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
|
||||
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
|
||||
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
|
||||
@@ -2087,6 +2471,46 @@
|
||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||
},
|
||||
"Rebase attempt {n}/3 failed: {err}": {
|
||||
"bg": "Rebase attempt {n}/3 failed: {err}",
|
||||
"de": "Rebase attempt {n}/3 failed: {err}",
|
||||
"en": "Rebase attempt {n}/3 failed: {err}",
|
||||
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||
"zh": "Rebase attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
|
||||
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
|
||||
},
|
||||
"Rebase failed with HTTP {status}: {message}": {
|
||||
"bg": "Rebase failed with HTTP {status}: {message}",
|
||||
"de": "Rebase failed with HTTP {status}: {message}",
|
||||
"en": "Rebase failed with HTTP {status}: {message}",
|
||||
"pl": "Rebase failed with HTTP {status}: {message}",
|
||||
"ru": "Rebase failed with HTTP {status}: {message}",
|
||||
"zh": "Rebase failed with HTTP {status}: {message}"
|
||||
},
|
||||
"Rebase successful.": {
|
||||
"bg": "Rebase successful.",
|
||||
"de": "Rebase successful.",
|
||||
"en": "Rebase successful.",
|
||||
"pl": "Rebase successful.",
|
||||
"ru": "Rebase successful.",
|
||||
"zh": "Rebase successful."
|
||||
},
|
||||
"Rebasing PR #{pr} via Gitea API...": {
|
||||
"bg": "Rebasing PR #{pr} via Gitea API...",
|
||||
"de": "Rebasing PR #{pr} via Gitea API...",
|
||||
"en": "Rebasing PR #{pr} via Gitea API...",
|
||||
"pl": "Rebasing PR #{pr} via Gitea API...",
|
||||
"ru": "Rebasing PR #{pr} via Gitea API...",
|
||||
"zh": "Rebasing PR #{pr} via Gitea API..."
|
||||
},
|
||||
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
|
||||
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
@@ -2127,14 +2551,6 @@
|
||||
"ru": "Release commit — skipping all post-merge jobs.",
|
||||
"zh": "Release commit — skipping all post-merge jobs."
|
||||
},
|
||||
"Automated CI commit (badge) — skipping post-merge jobs.": {
|
||||
"bg": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"de": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"en": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"pl": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"ru": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"zh": "Automated CI commit (badge) — skipping post-merge jobs."
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
@@ -2191,6 +2607,14 @@
|
||||
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||
},
|
||||
"Required tools missing.": {
|
||||
"en": "Required tools missing.",
|
||||
"bg": "Липсват задължителни инструменти.",
|
||||
"de": "Erforderliche Werkzeuge fehlen.",
|
||||
"pl": "Brak wymaganych narzędzi.",
|
||||
"ru": "Отсутствуют обязательные инструменты.",
|
||||
"zh": "缺少必需的工具。"
|
||||
},
|
||||
"Review body must be at least 50 characters.": {
|
||||
"bg": "Review body must be at least 50 characters.",
|
||||
"de": "Review body must be at least 50 characters.",
|
||||
@@ -2279,6 +2703,30 @@
|
||||
"ru": "Running: {scenario} on {platform}",
|
||||
"zh": "Running: {scenario} on {platform}"
|
||||
},
|
||||
"SSH key set up successfully": {
|
||||
"en": "SSH key set up successfully",
|
||||
"bg": "SSH ключът е настроен успешно",
|
||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||
"pl": "Klucz SSH skonfigurowany pomyślnie",
|
||||
"ru": "SSH-ключ успешно настроен",
|
||||
"zh": "SSH 密钥设置成功"
|
||||
},
|
||||
"SSH key setup skipped (no key provided)": {
|
||||
"en": "SSH key setup skipped (no key provided)",
|
||||
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
|
||||
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
|
||||
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
|
||||
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
|
||||
"zh": "SSH 密钥设置已跳过(未提供密钥)"
|
||||
},
|
||||
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
|
||||
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
|
||||
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
|
||||
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
|
||||
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
|
||||
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
|
||||
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
|
||||
},
|
||||
"Skip Vikunja title match check": {
|
||||
"bg": "Skip Vikunja title match check",
|
||||
"de": "Skip Vikunja title match check",
|
||||
@@ -2319,13 +2767,21 @@
|
||||
"ru": "Synced to latest origin/{branch}",
|
||||
"zh": "Synced to latest origin/{branch}"
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki...": {
|
||||
"bg": "Syncing {count} documentation pages to wiki...",
|
||||
"de": "Syncing {count} documentation pages to wiki...",
|
||||
"en": "Syncing {count} documentation pages to wiki...",
|
||||
"pl": "Synchronizowanie {count} stron dokumentacji do wiki...",
|
||||
"ru": "Syncing {count} documentation pages to wiki...",
|
||||
"zh": "Syncing {count} documentation pages to wiki..."
|
||||
"Syncing files...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Syncing files...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki via Git...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Syncing {count} documentation pages to wiki via Git...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Tag consistency check failed.": {
|
||||
"bg": "Tag consistency check failed.",
|
||||
@@ -2439,6 +2895,14 @@
|
||||
"ru": "Updated badge URLs in {filename}",
|
||||
"zh": "Updated badge URLs in {filename}"
|
||||
},
|
||||
"Updated documentation version references to v{version}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Updated documentation version references to v{version}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Updated version in {init}": {
|
||||
"bg": "Updated version in {init}",
|
||||
"de": "Updated version in {init}",
|
||||
@@ -2455,6 +2919,14 @@
|
||||
"ru": "Updated {changelog_file}",
|
||||
"zh": "Updated {changelog_file}"
|
||||
},
|
||||
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
|
||||
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
|
||||
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
|
||||
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
|
||||
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
|
||||
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
|
||||
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
|
||||
@@ -2511,6 +2983,38 @@
|
||||
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
|
||||
},
|
||||
"WARN: .venv has Python {version}, but >={req} is required.": {
|
||||
"en": "WARN: .venv has Python {version}, but >={req} is required.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
|
||||
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
|
||||
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
|
||||
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
|
||||
},
|
||||
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
|
||||
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
|
||||
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
|
||||
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
|
||||
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
|
||||
},
|
||||
"WARN: Could not determine Python version in .venv.": {
|
||||
"en": "WARN: Could not determine Python version in .venv.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
|
||||
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
|
||||
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
|
||||
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
|
||||
},
|
||||
"WARN: Could not parse Python version '{version}'.": {
|
||||
"en": "WARN: Could not parse Python version '{version}'.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
|
||||
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
|
||||
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
|
||||
"zh": "警告: 无法解析 Python 版本 '{version}'。"
|
||||
},
|
||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||
"bg": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"de": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
@@ -2527,22 +3031,6 @@
|
||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||
},
|
||||
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
|
||||
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
|
||||
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
|
||||
},
|
||||
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
|
||||
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
|
||||
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
|
||||
},
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||
@@ -2551,6 +3039,30 @@
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
},
|
||||
"WARNING: Version badge shows stale version (expected v{version}) — regenerating": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "WARNING: Version badge shows stale version (expected v{version}) — regenerating",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"WARNING: check_doc_versions --fix failed (rc={rc}): {err}": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "WARNING: check_doc_versions --fix failed (rc={rc}): {err}",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Waiting 5s for Gitea to process pushed commits...": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Waiting 5s for Gitea to process pushed commits...",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
@@ -2615,21 +3127,37 @@
|
||||
"ru": "Warning: repo-level runners query returned HTTP {status}",
|
||||
"zh": "Warning: repo-level runners query returned HTTP {status}"
|
||||
},
|
||||
"Wiki integrity check failed — {count} issue(s)": {
|
||||
"bg": "Wiki integrity check failed — {count} issue(s)",
|
||||
"de": "Wiki integrity check failed — {count} issue(s)",
|
||||
"en": "Wiki integrity check failed — {count} issue(s)",
|
||||
"pl": "Kontrola integralności wiki nie powiodła się — {count} problem(ów)",
|
||||
"ru": "Wiki integrity check failed — {count} issue(s)",
|
||||
"zh": "Wiki integrity check failed — {count} issue(s)"
|
||||
"Wiki repo not found or empty — initializing fresh.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Wiki repo not found or empty — initializing fresh.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Wiki verification failed — {failures} page(s) empty or mismatched": {
|
||||
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"pl": "Weryfikacja wiki nie powiodła się — {failures} strona(y) pusta lub niezgodna",
|
||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
"Wiki synced successfully.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Wiki synced successfully.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Wiki verification failed — could not clone wiki": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Wiki verification failed — could not clone wiki",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Wiki verification failed — {failures} page(s) missing": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Wiki verification failed — {failures} page(s) missing",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Wrote tag {tag} to GITHUB_OUTPUT.": {
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
@@ -2639,6 +3167,14 @@
|
||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
},
|
||||
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
|
||||
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
|
||||
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
|
||||
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
|
||||
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
|
||||
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
|
||||
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
|
||||
},
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"de": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
@@ -2647,6 +3183,30 @@
|
||||
"ru": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"zh": "[check-dep-docs] Passed: all dependencies are documented"
|
||||
},
|
||||
"[check-deps] All core tools present.": {
|
||||
"en": "[check-deps] All core tools present.",
|
||||
"bg": "[check-deps] Всички основни инструменти са налични.",
|
||||
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
|
||||
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
|
||||
"ru": "[check-deps] Все основные инструменты доступны.",
|
||||
"zh": "[check-deps] 所有核心工具均已就绪。"
|
||||
},
|
||||
"[check-deps] Verifying tools...": {
|
||||
"en": "[check-deps] Verifying tools...",
|
||||
"bg": "[check-deps] Проверка на инструментите...",
|
||||
"de": "[check-deps] Werkzeuge werden überprüft...",
|
||||
"pl": "[check-deps] Sprawdzanie narzędzi...",
|
||||
"ru": "[check-deps] Проверка инструментов...",
|
||||
"zh": "[check-deps] 正在验证工具..."
|
||||
},
|
||||
"[check-deps] Virtualenv .venv ready (Python {version}).": {
|
||||
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
|
||||
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
|
||||
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
|
||||
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
|
||||
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
|
||||
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
|
||||
},
|
||||
"[check-mutable-globals] Passed: no mutable path globals found": {
|
||||
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"de": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
@@ -2671,6 +3231,46 @@
|
||||
"ru": "[check_test_coverage] No changed files to check.",
|
||||
"zh": "[check_test_coverage] No changed files to check."
|
||||
},
|
||||
"[docker-login] Logged in to {registry}.": {
|
||||
"en": "[docker-login] Logged in to {registry}.",
|
||||
"bg": "[docker-login] Влязъл в {registry}.",
|
||||
"de": "[docker-login] Angemeldet bei {registry}.",
|
||||
"pl": "[docker-login] Zalogowano do {registry}.",
|
||||
"ru": "[docker-login] Выполнен вход в {registry}.",
|
||||
"zh": "[docker-login] 已登录到 {registry}。"
|
||||
},
|
||||
"[docker-login] Login to {registry} failed (continuing).": {
|
||||
"en": "[docker-login] Login to {registry} failed (continuing).",
|
||||
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
|
||||
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
|
||||
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
|
||||
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
|
||||
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
|
||||
},
|
||||
"[docker-login] Skipping {registry} (token {env} not set).": {
|
||||
"en": "[docker-login] Skipping {registry} (token {env} not set).",
|
||||
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
|
||||
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
|
||||
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
|
||||
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
|
||||
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
|
||||
},
|
||||
"[dry-run] No changes pushed.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "[dry-run] No changes pushed.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"[dry-run] Would commit and push wiki changes": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "[dry-run] Would commit and push wiki changes",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version} [skip ci]": {
|
||||
"bg": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"de": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
@@ -2703,13 +3303,13 @@
|
||||
"ru": "[dry-run] Would push commit to master",
|
||||
"zh": "[dry-run] Would push commit to master"
|
||||
},
|
||||
"[dry-run] Would sync page: {title} ({chars} chars)": {
|
||||
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"pl": "[dry-run] Zsynchronizowano by stronę: {title} ({chars} znaków)",
|
||||
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
"[dry-run] Would update doc version references via check_doc_versions --fix": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "[dry-run] Would update doc version references via check_doc_versions --fix",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"[dry-run] Would update {changelog_file}": {
|
||||
"bg": "[dry-run] Would update {changelog_file}",
|
||||
@@ -2727,6 +3327,38 @@
|
||||
"ru": "[dry-run] Would update {init}",
|
||||
"zh": "[dry-run] Would update {init}"
|
||||
},
|
||||
"[tofu-init] Done.": {
|
||||
"en": "[tofu-init] Done.",
|
||||
"bg": "[tofu-init] Готово.",
|
||||
"de": "[tofu-init] Fertig.",
|
||||
"pl": "[tofu-init] Gotowe.",
|
||||
"ru": "[tofu-init] Готово.",
|
||||
"zh": "[tofu-init] 完成。"
|
||||
},
|
||||
"[tofu-init] Initializing {dir}...": {
|
||||
"en": "[tofu-init] Initializing {dir}...",
|
||||
"bg": "[tofu-init] Инициализиране на {dir}...",
|
||||
"de": "[tofu-init] Initialisiere {dir}...",
|
||||
"pl": "[tofu-init] Inicjalizacja {dir}...",
|
||||
"ru": "[tofu-init] Инициализация {dir}...",
|
||||
"zh": "[tofu-init] 正在初始化 {dir}..."
|
||||
},
|
||||
"[tofu-{mode}] All configurations valid.": {
|
||||
"en": "[tofu-{mode}] All configurations valid.",
|
||||
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
|
||||
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
|
||||
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
|
||||
"ru": "[tofu-{mode}] Все конфигурации валидны.",
|
||||
"zh": "[tofu-{mode}] 所有配置有效。"
|
||||
},
|
||||
"[tofu-{mode}] Validating OpenTofu configurations...": {
|
||||
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
|
||||
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
|
||||
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
|
||||
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
|
||||
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
|
||||
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
|
||||
},
|
||||
"[tool.devx] missing required keys: {keys}": {
|
||||
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||
@@ -2879,6 +3511,14 @@
|
||||
"ru": "tea not installed — skipping login configuration.",
|
||||
"zh": "tea not installed — skipping login configuration."
|
||||
},
|
||||
"tofu command failed in {dir}: {error}": {
|
||||
"en": "tofu command failed in {dir}: {error}",
|
||||
"bg": "командата tofu не успя в {dir}: {error}",
|
||||
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
|
||||
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
|
||||
"ru": "команда tofu не удалась в {dir}: {error}",
|
||||
"zh": "tofu 命令在 {dir} 中失败: {error}"
|
||||
},
|
||||
"unknown": {
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
@@ -2887,286 +3527,6 @@
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
},
|
||||
"{file} already exists. Use --force to overwrite.": {
|
||||
"bg": "{file} already exists. Use --force to overwrite.",
|
||||
"de": "{file} already exists. Use --force to overwrite.",
|
||||
"en": "{file} already exists. Use --force to overwrite.",
|
||||
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
},
|
||||
"{separator}": {
|
||||
"bg": "{separator}",
|
||||
"de": "{separator}",
|
||||
"en": "{separator}",
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
},
|
||||
"Failed to push release commit after 3 attempts. Manual intervention required.": {
|
||||
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
|
||||
},
|
||||
"Push attempt {n}/3 failed: {err}": {
|
||||
"bg": "Push attempt {n}/3 failed: {err}",
|
||||
"de": "Push attempt {n}/3 failed: {err}",
|
||||
"en": "Push attempt {n}/3 failed: {err}",
|
||||
"pl": "Push attempt {n}/3 failed: {err}",
|
||||
"ru": "Push attempt {n}/3 failed: {err}",
|
||||
"zh": "Push attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Rebase attempt {n}/3 failed: {err}": {
|
||||
"bg": "Rebase attempt {n}/3 failed: {err}",
|
||||
"de": "Rebase attempt {n}/3 failed: {err}",
|
||||
"en": "Rebase attempt {n}/3 failed: {err}",
|
||||
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||
"zh": "Rebase attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||
},
|
||||
"Branch is already up-to-date with origin/master.": {
|
||||
"bg": "Branch is already up-to-date with origin/master.",
|
||||
"de": "Branch is already up-to-date with origin/master.",
|
||||
"en": "Branch is already up-to-date with origin/master.",
|
||||
"pl": "Branch is already up-to-date with origin/master.",
|
||||
"ru": "Branch is already up-to-date with origin/master.",
|
||||
"zh": "Branch is already up-to-date with origin/master."
|
||||
},
|
||||
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
|
||||
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
|
||||
},
|
||||
"Branch is {count} commit(s) behind master. Rebasing...": {
|
||||
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"de": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"en": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
|
||||
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
|
||||
},
|
||||
"Cannot rebase: not on a branch (detached HEAD).": {
|
||||
"bg": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"de": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"en": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"pl": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"ru": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"zh": "Cannot rebase: not on a branch (detached HEAD)."
|
||||
},
|
||||
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
|
||||
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
|
||||
},
|
||||
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
|
||||
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
|
||||
},
|
||||
"Fetch failed: {error}": {
|
||||
"bg": "Fetch failed: {error}",
|
||||
"de": "Fetch failed: {error}",
|
||||
"en": "Fetch failed: {error}",
|
||||
"pl": "Fetch failed: {error}",
|
||||
"ru": "Fetch failed: {error}",
|
||||
"zh": "Fetch failed: {error}"
|
||||
},
|
||||
"Fetching origin/master...": {
|
||||
"bg": "Fetching origin/master...",
|
||||
"de": "Fetching origin/master...",
|
||||
"en": "Fetching origin/master...",
|
||||
"pl": "Fetching origin/master...",
|
||||
"ru": "Fetching origin/master...",
|
||||
"zh": "Fetching origin/master..."
|
||||
},
|
||||
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
|
||||
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
|
||||
},
|
||||
"Force-pushing...": {
|
||||
"bg": "Force-pushing...",
|
||||
"de": "Force-pushing...",
|
||||
"en": "Force-pushing...",
|
||||
"pl": "Force-pushing...",
|
||||
"ru": "Force-pushing...",
|
||||
"zh": "Force-pushing..."
|
||||
},
|
||||
"Nothing to push.": {
|
||||
"bg": "Nothing to push.",
|
||||
"de": "Nothing to push.",
|
||||
"en": "Nothing to push.",
|
||||
"pl": "Nothing to push.",
|
||||
"ru": "Nothing to push.",
|
||||
"zh": "Nothing to push."
|
||||
},
|
||||
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
||||
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
|
||||
},
|
||||
"Pushed {branch} to origin.": {
|
||||
"bg": "Pushed {branch} to origin.",
|
||||
"de": "Pushed {branch} to origin.",
|
||||
"en": "Pushed {branch} to origin.",
|
||||
"pl": "Pushed {branch} to origin.",
|
||||
"ru": "Pushed {branch} to origin.",
|
||||
"zh": "Pushed {branch} to origin."
|
||||
},
|
||||
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
|
||||
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
|
||||
},
|
||||
"Rebase failed with HTTP {status}: {message}": {
|
||||
"bg": "Rebase failed with HTTP {status}: {message}",
|
||||
"de": "Rebase failed with HTTP {status}: {message}",
|
||||
"en": "Rebase failed with HTTP {status}: {message}",
|
||||
"pl": "Rebase failed with HTTP {status}: {message}",
|
||||
"ru": "Rebase failed with HTTP {status}: {message}",
|
||||
"zh": "Rebase failed with HTTP {status}: {message}"
|
||||
},
|
||||
"Rebase successful.": {
|
||||
"bg": "Rebase successful.",
|
||||
"de": "Rebase successful.",
|
||||
"en": "Rebase successful.",
|
||||
"pl": "Rebase successful.",
|
||||
"ru": "Rebase successful.",
|
||||
"zh": "Rebase successful."
|
||||
},
|
||||
"Rebasing PR #{pr} via Gitea API...": {
|
||||
"bg": "Rebasing PR #{pr} via Gitea API...",
|
||||
"de": "Rebasing PR #{pr} via Gitea API...",
|
||||
"en": "Rebasing PR #{pr} via Gitea API...",
|
||||
"pl": "Rebasing PR #{pr} via Gitea API...",
|
||||
"ru": "Rebasing PR #{pr} via Gitea API...",
|
||||
"zh": "Rebasing PR #{pr} via Gitea API..."
|
||||
},
|
||||
"Ensuring standard labels...": {
|
||||
"bg": "Ensuring standard labels...",
|
||||
"de": "Ensuring standard labels...",
|
||||
"en": "Ensuring standard labels...",
|
||||
"pl": "Ensuring standard labels...",
|
||||
"ru": "Ensuring standard labels...",
|
||||
"zh": "Ensuring standard labels..."
|
||||
},
|
||||
" - {count} standard labels verified": {
|
||||
"bg": " - {count} standard labels verified",
|
||||
"de": " - {count} standard labels verified",
|
||||
"en": " - {count} standard labels verified",
|
||||
"pl": " - {count} standard labels verified",
|
||||
"ru": " - {count} standard labels verified",
|
||||
"zh": " - {count} standard labels verified"
|
||||
},
|
||||
"[check-deps] Virtualenv .venv ready (Python {version}).": {
|
||||
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
|
||||
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
|
||||
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
|
||||
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
|
||||
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
|
||||
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
|
||||
},
|
||||
"{level}: {tool} not found.{hint}": {
|
||||
"en": "{level}: {tool} not found.{hint}",
|
||||
"bg": "{level}: {tool} не е намерен.{hint}",
|
||||
"de": "{level}: {tool} nicht gefunden.{hint}",
|
||||
"pl": "{level}: {tool} nie znaleziono.{hint}",
|
||||
"ru": "{level}: {tool} не найден.{hint}",
|
||||
"zh": "{level}: 未找到 {tool}。{hint}"
|
||||
},
|
||||
"WARN: Could not determine Python version in .venv.": {
|
||||
"en": "WARN: Could not determine Python version in .venv.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
|
||||
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
|
||||
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
|
||||
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
|
||||
},
|
||||
"WARN: Could not parse Python version '{version}'.": {
|
||||
"en": "WARN: Could not parse Python version '{version}'.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
|
||||
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
|
||||
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
|
||||
"zh": "警告: 无法解析 Python 版本 '{version}'。"
|
||||
},
|
||||
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
|
||||
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
|
||||
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
|
||||
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
|
||||
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
|
||||
},
|
||||
"[docker-login] Logged in to {registry}.": {
|
||||
"en": "[docker-login] Logged in to {registry}.",
|
||||
"bg": "[docker-login] Влязъл в {registry}.",
|
||||
"de": "[docker-login] Angemeldet bei {registry}.",
|
||||
"pl": "[docker-login] Zalogowano do {registry}.",
|
||||
"ru": "[docker-login] Выполнен вход в {registry}.",
|
||||
"zh": "[docker-login] 已登录到 {registry}。"
|
||||
},
|
||||
"[docker-login] Login to {registry} failed (continuing).": {
|
||||
"en": "[docker-login] Login to {registry} failed (continuing).",
|
||||
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
|
||||
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
|
||||
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
|
||||
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
|
||||
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
|
||||
},
|
||||
"[docker-login] Skipping {registry} (token {env} not set).": {
|
||||
"en": "[docker-login] Skipping {registry} (token {env} not set).",
|
||||
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
|
||||
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
|
||||
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
|
||||
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
|
||||
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
|
||||
},
|
||||
"{env} is not set. Set it in your .env file.": {
|
||||
"en": "{env} is not set. Set it in your .env file.",
|
||||
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
|
||||
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
|
||||
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
|
||||
"ru": "{env} не задан. Установите его в файле .env.",
|
||||
"zh": "{env} 未设置。请在 .env 文件中设置。"
|
||||
},
|
||||
"{env} is not set. Set it in your .env file or pass it as an environment variable.": {
|
||||
"en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
|
||||
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
|
||||
@@ -3175,188 +3535,76 @@
|
||||
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
|
||||
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
|
||||
},
|
||||
"Login to {registry} failed: {error}": {
|
||||
"en": "Login to {registry} failed: {error}",
|
||||
"bg": "Влизането в {registry} не успя: {error}",
|
||||
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
|
||||
"pl": "Logowanie do {registry} nie powiodło się: {error}",
|
||||
"ru": "Ошибка входа в {registry}: {error}",
|
||||
"zh": "登录 {registry} 失败: {error}"
|
||||
"{env} is not set. Set it in your .env file.": {
|
||||
"en": "{env} is not set. Set it in your .env file.",
|
||||
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
|
||||
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
|
||||
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
|
||||
"ru": "{env} не задан. Установите его в файле .env.",
|
||||
"zh": "{env} 未设置。请在 .env 文件中设置。"
|
||||
},
|
||||
"tofu command failed in {dir}: {error}": {
|
||||
"en": "tofu command failed in {dir}: {error}",
|
||||
"bg": "командата tofu не успя в {dir}: {error}",
|
||||
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
|
||||
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
|
||||
"ru": "команда tofu не удалась в {dir}: {error}",
|
||||
"zh": "tofu 命令在 {dir} 中失败: {error}"
|
||||
"{file} already exists. Use --force to overwrite.": {
|
||||
"bg": "{file} already exists. Use --force to overwrite.",
|
||||
"de": "{file} already exists. Use --force to overwrite.",
|
||||
"en": "{file} already exists. Use --force to overwrite.",
|
||||
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
},
|
||||
"WARN: .venv has Python {version}, but >={req} is required.": {
|
||||
"en": "WARN: .venv has Python {version}, but >={req} is required.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
|
||||
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
|
||||
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
|
||||
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
|
||||
"{level}: {tool} not found.{hint}": {
|
||||
"en": "{level}: {tool} not found.{hint}",
|
||||
"bg": "{level}: {tool} не е намерен.{hint}",
|
||||
"de": "{level}: {tool} nicht gefunden.{hint}",
|
||||
"pl": "{level}: {tool} nie znaleziono.{hint}",
|
||||
"ru": "{level}: {tool} не найден.{hint}",
|
||||
"zh": "{level}: 未找到 {tool}。{hint}"
|
||||
},
|
||||
" -> {dir}": {
|
||||
"en": " -> {dir}",
|
||||
"bg": " -> {dir}",
|
||||
"de": " -> {dir}",
|
||||
"pl": " -> {dir}",
|
||||
"ru": " -> {dir}",
|
||||
"zh": " -> {dir}"
|
||||
"{separator}": {
|
||||
"bg": "{separator}",
|
||||
"de": "{separator}",
|
||||
"en": "{separator}",
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
},
|
||||
"[tofu-init] Initializing {dir}...": {
|
||||
"en": "[tofu-init] Initializing {dir}...",
|
||||
"bg": "[tofu-init] Инициализиране на {dir}...",
|
||||
"de": "[tofu-init] Initialisiere {dir}...",
|
||||
"pl": "[tofu-init] Inicjalizacja {dir}...",
|
||||
"ru": "[tofu-init] Инициализация {dir}...",
|
||||
"zh": "[tofu-init] 正在初始化 {dir}..."
|
||||
"Allow empty tag (PR mode where SHA is concrete).": {
|
||||
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
||||
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
||||
"en": "Allow empty tag (PR mode where SHA is concrete).",
|
||||
"pl": "Zezwalaj na pusty tag (tryb PR, w którym SHA jest konkretne).",
|
||||
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||
},
|
||||
"[tofu-init] Done.": {
|
||||
"en": "[tofu-init] Done.",
|
||||
"bg": "[tofu-init] Готово.",
|
||||
"de": "[tofu-init] Fertig.",
|
||||
"pl": "[tofu-init] Gotowe.",
|
||||
"ru": "[tofu-init] Готово.",
|
||||
"zh": "[tofu-init] 完成。"
|
||||
"Git tag or ref that was deployed": {
|
||||
"bg": "Git таг или референция, която беше разгърната",
|
||||
"de": "Git-Tag oder Ref, der bereitgestellt wurde",
|
||||
"en": "Git tag or ref that was deployed",
|
||||
"pl": "Tag Git lub ref, który został wdrożony",
|
||||
"ru": "Git-тег или ссылка, которые были развёрнуты",
|
||||
"zh": "已部署的 Git 标签或引用"
|
||||
},
|
||||
"[tofu-{mode}] Validating OpenTofu configurations...": {
|
||||
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
|
||||
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
|
||||
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
|
||||
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
|
||||
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
|
||||
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
|
||||
"Git tag to deploy (e.g. v0.28.1).": {
|
||||
"bg": "Git таг за разгръщане (напр. v0.28.1).",
|
||||
"de": "Git-Tag für Bereitstellung (z.B. v0.28.1).",
|
||||
"en": "Git tag to deploy (e.g. v0.28.1).",
|
||||
"pl": "Tag Git do wdrożenia (np. v0.28.1).",
|
||||
"ru": "Git-тег для развёртывания (напр. v0.28.1).",
|
||||
"zh": "要部署的 Git 标签(例如 v0.28.1)。"
|
||||
},
|
||||
"[tofu-{mode}] All configurations valid.": {
|
||||
"en": "[tofu-{mode}] All configurations valid.",
|
||||
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
|
||||
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
|
||||
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
|
||||
"ru": "[tofu-{mode}] Все конфигурации валидны.",
|
||||
"zh": "[tofu-{mode}] 所有配置有效。"
|
||||
"Write deploy-ref to $GITHUB_OUTPUT file.": {
|
||||
"bg": "Запиши deploy-ref в $GITHUB_OUTPUT файла.",
|
||||
"de": "Deploy-ref in $GITHUB_OUTPUT-Datei schreiben.",
|
||||
"en": "Write deploy-ref to $GITHUB_OUTPUT file.",
|
||||
"pl": "Zapisz deploy-ref do pliku $GITHUB_OUTPUT.",
|
||||
"ru": "Записать deploy-ref в файл $GITHUB_OUTPUT.",
|
||||
"zh": "将 deploy-ref 写入 $GITHUB_OUTPUT 文件。"
|
||||
},
|
||||
"[check-deps] Verifying tools...": {
|
||||
"en": "[check-deps] Verifying tools...",
|
||||
"bg": "[check-deps] Проверка на инструментите...",
|
||||
"de": "[check-deps] Werkzeuge werden überprüft...",
|
||||
"pl": "[check-deps] Sprawdzanie narzędzi...",
|
||||
"ru": "[check-deps] Проверка инструментов...",
|
||||
"zh": "[check-deps] 正在验证工具..."
|
||||
},
|
||||
" {tool}: found at {path}": {
|
||||
"en": " {tool}: found at {path}",
|
||||
"bg": " {tool}: намерен на {path}",
|
||||
"de": " {tool}: gefunden unter {path}",
|
||||
"pl": " {tool}: znaleziono w {path}",
|
||||
"ru": " {tool}: найден в {path}",
|
||||
"zh": " {tool}: 在 {path} 找到"
|
||||
},
|
||||
" Run 'make install-checkmake' to install the Makefile linter.": {
|
||||
"en": " Run 'make install-checkmake' to install the Makefile linter.",
|
||||
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
|
||||
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
|
||||
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
|
||||
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
|
||||
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
|
||||
},
|
||||
"Required tools missing.": {
|
||||
"en": "Required tools missing.",
|
||||
"bg": "Липсват задължителни инструменти.",
|
||||
"de": "Erforderliche Werkzeuge fehlen.",
|
||||
"pl": "Brak wymaganych narzędzi.",
|
||||
"ru": "Отсутствуют обязательные инструменты.",
|
||||
"zh": "缺少必需的工具。"
|
||||
},
|
||||
"[check-deps] All core tools present.": {
|
||||
"en": "[check-deps] All core tools present.",
|
||||
"bg": "[check-deps] Всички основни инструменти са налични.",
|
||||
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
|
||||
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
|
||||
"ru": "[check-deps] Все основные инструменты доступны.",
|
||||
"zh": "[check-deps] 所有核心工具均已就绪。"
|
||||
},
|
||||
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
|
||||
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
|
||||
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
|
||||
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
|
||||
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
|
||||
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
|
||||
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
|
||||
},
|
||||
"Failed to start ssh-agent: {error}": {
|
||||
"en": "Failed to start ssh-agent: {error}",
|
||||
"bg": "Неуспешно стартиране на ssh-agent: {error}",
|
||||
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
|
||||
"pl": "Nie udało się uruchomić ssh-agent: {error}",
|
||||
"ru": "Не удалось запустить ssh-agent: {error}",
|
||||
"zh": "启动 ssh-agent 失败: {error}"
|
||||
},
|
||||
"SSH key set up successfully": {
|
||||
"en": "SSH key set up successfully",
|
||||
"bg": "SSH ключът е настроен успешно",
|
||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||
"pl": "Klucz SSH skonfigurowany pomyślnie",
|
||||
"ru": "SSH-ключ успешно настроен",
|
||||
"zh": "SSH 密钥设置成功"
|
||||
},
|
||||
"SSH key setup skipped (no key provided)": {
|
||||
"en": "SSH key setup skipped (no key provided)",
|
||||
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
|
||||
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
|
||||
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
|
||||
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
|
||||
"zh": "SSH 密钥设置已跳过(未提供密钥)"
|
||||
},
|
||||
"Found {count} unsafe identity check(s) in integration tests.": {
|
||||
"en": "Found {count} unsafe identity check(s) in integration tests.",
|
||||
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
|
||||
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
|
||||
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
|
||||
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
|
||||
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
|
||||
},
|
||||
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
|
||||
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
|
||||
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
|
||||
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
|
||||
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
|
||||
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
|
||||
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
|
||||
},
|
||||
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
|
||||
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
|
||||
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
|
||||
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
|
||||
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
|
||||
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
|
||||
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
|
||||
},
|
||||
"Directory to scan (default: tests/integration). Can be repeated.": {
|
||||
"en": "Directory to scan (default: tests/integration). Can be repeated.",
|
||||
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
|
||||
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
|
||||
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
|
||||
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
|
||||
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
|
||||
},
|
||||
"Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.": {
|
||||
"bg": "Неуспешно извличане на съществуващи wiki страници след повторни опити: {error}. Прекратяване, за да се избегне създаване на дублирани страници.",
|
||||
"de": "Abrufen bestehender Wiki-Seiten nach Wiederholungen fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.",
|
||||
"en": "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.",
|
||||
"pl": "Nie udało się wylistować istniejących stron wiki po ponownych próbach: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.",
|
||||
"ru": "Не удалось получить список существующих wiki-страниц после повторных попыток: {error}. Прерывание, чтобы избежать создания дубликатов страниц.",
|
||||
"zh": "重试后列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。"
|
||||
},
|
||||
" Page '{title}' already exists (stale list). Re-listing and updating...": {
|
||||
"bg": " Страницата '{title}' вече съществува (остарял списък). Пресписване и обновяване...",
|
||||
"de": " Seite '{title}' existiert bereits (veraltete Liste). Neu auflisten und aktualisieren...",
|
||||
"en": " Page '{title}' already exists (stale list). Re-listing and updating...",
|
||||
"pl": " Strona '{title}' już istnieje (nieaktualna lista). Ponowne listowanie i aktualizacja...",
|
||||
"ru": " Страница '{title}' уже существует (устаревший список). Повторное получение списка и обновление...",
|
||||
"zh": " 页面 '{title}' 已存在(列表过期)。重新列出并更新..."
|
||||
"Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.": {
|
||||
"bg": "Заглавието на задачата във Vikunja '{title}' започва с '{prefix}:'. Заглавието на задачата НЕ трябва да съдържа префикса '{prefix}' — той се добавя автоматично към заглавието на PR. Актуализирайте заглавието на задачата във Vikunja, за да премахнете префикса.",
|
||||
"de": "Der Vikunja-Aufgabentitel '{title}' beginnt mit '{prefix}:'. Der Aufgabentitel darf NICHT den Präfix '{prefix}' enthalten — er wird automatisch zum PR-Titel hinzugefügt. Aktualisieren Sie den Vikunja-Aufgabentitel, um den Präfix zu entfernen.",
|
||||
"en": "Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.",
|
||||
"pl": "Tytuł zadania Vikunja '{title}' zaczyna się od '{prefix}:'. Tytuł zadania nie powinien zawierać prefiksu '{prefix}' — jest on automatycznie dodawany do tytułu PR. Zaktualizuj tytuł zadania Vikunja, aby usunąć prefiks.",
|
||||
"ru": "Заголовок задачи Vikunja '{title}' начинается с '{prefix}:'. Заголовок задачи НЕ должен включать префикс '{prefix}' — он автоматически добавляется к заголовку PR. Обновите заголовок задачи Vikunja, чтобы удалить префикс.",
|
||||
"zh": "Vikunja 任务标题 '{title}' 以 '{prefix}:' 开头。任务标题不应包含 '{prefix}' 前缀 — 它会自动添加到 PR 标题中。请更新 Vikunja 任务标题以删除前缀。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Typed confirmation validation for destructive operations.
|
||||
|
||||
Ensures the user typed an exact confirmation phrase before proceeding
|
||||
with dangerous operations (e.g. production deploys, database migrations).
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.confirm import validate_confirmation
|
||||
|
||||
if not validate_confirmation(user_input, expected="deploy-production"):
|
||||
raise SystemExit("Confirmation does not match")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def validate_confirmation(confirm: str, expected: str) -> bool:
|
||||
"""Check if confirmation text matches the expected phrase.
|
||||
|
||||
Args:
|
||||
confirm: The confirmation text entered by the user.
|
||||
expected: The exact phrase that must be matched.
|
||||
|
||||
Returns:
|
||||
True if confirmation matches exactly, False otherwise.
|
||||
"""
|
||||
return confirm == expected
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Cryptographic secret generation helpers.
|
||||
|
||||
Provides safe secret/password generators that avoid shell-option
|
||||
interpretation issues (e.g. leading ``-`` being parsed as a flag by
|
||||
``su -c`` in Docker entrypoints).
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.crypto import generate_secret, generate_password
|
||||
|
||||
api_key = generate_secret()
|
||||
db_password = generate_password(length=32)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
_SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?"
|
||||
_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
_LOWER = "abcdefghijklmnopqrstuvwxyz"
|
||||
_DIGITS = "0123456789"
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Generate a URL-safe secret that never starts with ``-``.
|
||||
|
||||
A leading ``-`` causes passwords to be interpreted as command-line
|
||||
options when passed through shell expansion chains (e.g. Nextcloud's
|
||||
Docker entrypoint uses ``su -c`` which strips quoting).
|
||||
|
||||
Returns:
|
||||
A 43-character URL-safe base64 secret.
|
||||
"""
|
||||
value = secrets.token_urlsafe(32)
|
||||
while value.startswith("-"):
|
||||
value = secrets.token_urlsafe(32)
|
||||
return value
|
||||
|
||||
|
||||
def generate_password(length: int = 32) -> str:
|
||||
"""Generate a password guaranteed to contain upper, lower, digit, and symbol.
|
||||
|
||||
The first character is always alphanumeric to avoid being interpreted
|
||||
as a command-line option when passed through shell expansion chains.
|
||||
|
||||
Args:
|
||||
length: Desired password length (minimum 4).
|
||||
|
||||
Returns:
|
||||
A password string with guaranteed character class coverage.
|
||||
"""
|
||||
pools = [_UPPER, _LOWER, _DIGITS, _SYMBOLS]
|
||||
chars = [secrets.choice(p) for p in pools]
|
||||
all_chars = "".join(pools)
|
||||
chars += [secrets.choice(all_chars) for _ in range(length - len(pools))]
|
||||
secrets.SystemRandom().shuffle(chars)
|
||||
while chars[0] in _SYMBOLS:
|
||||
secrets.SystemRandom().shuffle(chars)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def generate_hex_secret(length: int = 32) -> str:
|
||||
"""Generate a hexadecimal secret of the given length.
|
||||
|
||||
Args:
|
||||
length: Desired number of hex characters (doubled internally
|
||||
since ``token_hex`` produces pairs).
|
||||
|
||||
Returns:
|
||||
A hexadecimal string.
|
||||
"""
|
||||
return secrets.token_hex(length // 2)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""File-locked JSON registry for local state management.
|
||||
|
||||
Provides a simple JSON-backed key-value store with ``fcntl`` file
|
||||
locking for safe concurrent access. Useful for CLI tools that need
|
||||
to track remote resources (runners, VMs, deployments) on the local
|
||||
machine.
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.json_registry import JsonRegistry
|
||||
|
||||
registry = JsonRegistry(Path("~/.local/share/myapp/state.json"))
|
||||
registry.add("item1", host="10.0.0.1", user="deploy")
|
||||
info = registry.get("item1")
|
||||
registry.remove("item1")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fcntl
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
class JsonRegistry:
|
||||
"""Manages a local JSON file mapping names to arbitrary metadata.
|
||||
|
||||
Uses ``fcntl`` for file locking (shared lock for reads, exclusive
|
||||
lock for writes) to prevent race conditions in concurrent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
"""Initialise the registry.
|
||||
|
||||
Args:
|
||||
path: Path to the JSON file. Defaults to
|
||||
``~/.local/share/devx/registry.json``.
|
||||
"""
|
||||
self._path = path or Path.home() / ".local" / "share" / "devx" / "registry.json"
|
||||
self._data: dict[str, dict[str, Any]] = self._load()
|
||||
|
||||
def _load(self) -> dict[str, dict[str, Any]]:
|
||||
if not self._path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(self._path) as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
data: Any = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return cast(dict[str, dict[str, Any]], data)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._path, "w") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(self._data, f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def add(self, name: str, **fields: Any) -> None:
|
||||
"""Register or overwrite an entry in the registry.
|
||||
|
||||
Args:
|
||||
name: Unique key for the entry.
|
||||
**fields: Arbitrary metadata fields to store.
|
||||
"""
|
||||
self._data[name] = {
|
||||
**fields,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
self._save()
|
||||
|
||||
def get(self, name: str) -> dict[str, Any] | None:
|
||||
"""Retrieve entry metadata by name.
|
||||
|
||||
Args:
|
||||
name: Key to look up.
|
||||
|
||||
Returns:
|
||||
A copy of the entry's metadata, or None if not found.
|
||||
"""
|
||||
info = self._data.get(name)
|
||||
if info:
|
||||
return copy.deepcopy(info)
|
||||
return None
|
||||
|
||||
def remove(self, name: str) -> None:
|
||||
"""Remove an entry from the registry.
|
||||
|
||||
Args:
|
||||
name: Key to remove. No-op if not found.
|
||||
"""
|
||||
if name in self._data:
|
||||
del self._data[name]
|
||||
self._save()
|
||||
|
||||
def list(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return a copy of all registered entries.
|
||||
|
||||
Returns:
|
||||
Dict mapping names to metadata copies.
|
||||
"""
|
||||
return {name: copy.deepcopy(info) for name, info in self._data.items()}
|
||||
|
||||
def update(self, name: str, **fields: Any) -> None:
|
||||
"""Update fields for an existing entry.
|
||||
|
||||
Args:
|
||||
name: Key to update.
|
||||
**fields: Fields to update (None values are skipped).
|
||||
|
||||
Raises:
|
||||
KeyError: If the entry doesn't exist.
|
||||
"""
|
||||
if name not in self._data:
|
||||
raise KeyError(name)
|
||||
self._data[name].update({k: v for k, v in fields.items() if v is not None})
|
||||
self._save()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""XDG-compliant logging configuration for CLI tools.
|
||||
|
||||
Provides a standardised logging setup that writes to
|
||||
``~/.local/state/<app>/logs/<app>.log`` following the XDG state
|
||||
directory specification. Console output is handled separately by
|
||||
the application (e.g. via ``click.echo``).
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("myapp")
|
||||
logger.info("Application started")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_logger(name: str = "devx") -> logging.Logger:
|
||||
"""Return a configured logger that writes to an XDG state directory.
|
||||
|
||||
All messages (including DEBUG) are written to
|
||||
``~/.local/state/<name>/logs/<name>.log``. Console output is
|
||||
expected to be handled by the application via ``click.echo``.
|
||||
|
||||
Args:
|
||||
name: Logger name and subdirectory name for log files.
|
||||
|
||||
Returns:
|
||||
A configured :class:`logging.Logger` instance.
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
log_dir = Path.home() / ".local" / "state" / name / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_dir / f"{name}.log")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Network connectivity helpers.
|
||||
|
||||
Provides retry-aware HTTP connectivity checks and SSH availability
|
||||
checks for deployment workflows. Uses ``tenacity`` for exponential
|
||||
backoff retry logic.
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.network import check_http_connectivity, wait_for_ssh
|
||||
|
||||
check_http_connectivity("https://auth.example.com")
|
||||
wait_for_ssh("178.105.254.83")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import requests
|
||||
from tenacity import (
|
||||
Retrying,
|
||||
before_sleep_log,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
|
||||
def check_http_connectivity(
|
||||
base_url: str,
|
||||
max_attempts: int = 30,
|
||||
*,
|
||||
verify: bool = True,
|
||||
sleep: Callable[[float], None] | None = None,
|
||||
) -> None:
|
||||
"""Verify HTTP reachability of *base_url* with retry.
|
||||
|
||||
Uses tenacity for retry with exponential backoff (2 s min, 10 s max).
|
||||
|
||||
Args:
|
||||
base_url: URL to check via GET request.
|
||||
max_attempts: Maximum retry attempts.
|
||||
verify: Whether to verify TLS certificates.
|
||||
sleep: Custom sleep function for testing (defaults to ``time.sleep``).
|
||||
|
||||
Raises:
|
||||
requests.exceptions.ConnectionError: If the URL is not reachable
|
||||
after *max_attempts*.
|
||||
"""
|
||||
retrying = Retrying(
|
||||
stop=stop_after_attempt(max_attempts),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=10),
|
||||
retry=retry_if_exception_type(requests.exceptions.ConnectionError),
|
||||
before_sleep=before_sleep_log(logging.getLogger("devx.utils.network"), logging.WARNING),
|
||||
sleep=sleep if sleep is not None else time.sleep,
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
def _check() -> None:
|
||||
requests.get(base_url, timeout=10, verify=verify) # nosec B501
|
||||
|
||||
retrying(_check)
|
||||
|
||||
|
||||
def wait_for_ssh(
|
||||
host: str,
|
||||
port: int = 22,
|
||||
max_attempts: int = 30,
|
||||
interval: int = 10,
|
||||
*,
|
||||
sleep: Callable[[float], None] | None = None,
|
||||
) -> None:
|
||||
"""Wait for SSH to be available on a host using a pure-Python socket check.
|
||||
|
||||
Uses socket instead of ``nc(1)`` so it works on CI runners without
|
||||
netcat. Uses exponential backoff: starts at 2 s, doubles each
|
||||
attempt up to 10 s max.
|
||||
|
||||
Args:
|
||||
host: VM IP address or hostname.
|
||||
port: SSH port (default 22).
|
||||
max_attempts: Maximum number of connection attempts.
|
||||
interval: Base interval for backoff calculation (seconds).
|
||||
sleep: Custom sleep function for testing (defaults to ``time.sleep``).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If SSH is not available after *max_attempts*.
|
||||
"""
|
||||
_sleep = sleep if sleep is not None else time.sleep
|
||||
for i in range(max_attempts):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=5):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
if i < max_attempts - 1:
|
||||
wait = min(2 * (2**i), 10)
|
||||
_sleep(wait)
|
||||
raise RuntimeError(f"SSH not available on {host}:{port} after {max_attempts} attempts")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""SSH helpers for running commands on remote hosts.
|
||||
|
||||
Provides a simple wrapper around the ``ssh`` CLI for executing commands
|
||||
on remote machines (e.g. customer VMs, CI runners) without requiring
|
||||
Ansible. Includes a pure-Python ``wait_for_ssh`` that uses socket
|
||||
instead of ``nc(1)`` so it works on minimal CI containers.
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.ssh import ssh_exec, wait_for_ssh
|
||||
|
||||
wait_for_ssh("178.105.254.83")
|
||||
result = ssh_exec("178.105.254.83", "uname -a")
|
||||
print(result.stdout)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import time
|
||||
|
||||
SSH_CONNECT_TIMEOUT = "10"
|
||||
SSH_HOST_KEY_CHECKING = "no"
|
||||
|
||||
|
||||
def ssh_exec(
|
||||
host: str,
|
||||
command: str,
|
||||
*,
|
||||
user: str = "deploy",
|
||||
timeout: int = 30,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run *command* on *host* via SSH and return the result.
|
||||
|
||||
Args:
|
||||
host: VM IP address or hostname.
|
||||
command: Shell command to execute on the remote host.
|
||||
user: SSH user (default ``deploy``).
|
||||
timeout: Subprocess timeout in seconds.
|
||||
check: If True, raise ``CalledProcessError`` on non-zero exit.
|
||||
|
||||
Returns:
|
||||
The completed process result with stdout/stderr captured.
|
||||
"""
|
||||
result = subprocess.run( # nosec B603, B607, B607
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
f"StrictHostKeyChecking={SSH_HOST_KEY_CHECKING}",
|
||||
"-o",
|
||||
f"ConnectTimeout={SSH_CONNECT_TIMEOUT}",
|
||||
f"{user}@{host}",
|
||||
command,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
print(f"SSH command failed on {host}: {command}", file=sys.stderr)
|
||||
print(f" stdout: {result.stdout.strip()}", file=sys.stderr)
|
||||
print(f" stderr: {result.stderr.strip()}", file=sys.stderr)
|
||||
result.check_returncode()
|
||||
return result
|
||||
|
||||
|
||||
def docker_exec_on_vm(
|
||||
host: str,
|
||||
container: str,
|
||||
command: str,
|
||||
*,
|
||||
user: str = "deploy",
|
||||
db_user: str | None = None,
|
||||
db_name: str | None = None,
|
||||
timeout: int = 30,
|
||||
) -> str:
|
||||
"""Run a command inside a Docker container on a remote VM via SSH.
|
||||
|
||||
For PostgreSQL commands, set *db_user* and *db_name* to run
|
||||
``psql -U <db_user> -d <db_name> -c <command>`` inside the container.
|
||||
|
||||
Args:
|
||||
host: VM IP address or hostname.
|
||||
container: Docker container name on the remote host.
|
||||
command: Command to execute inside the container (or SQL if db_user/db_name set).
|
||||
user: SSH user (default ``deploy``).
|
||||
db_user: PostgreSQL user name (enables psql mode).
|
||||
db_name: PostgreSQL database name (enables psql mode).
|
||||
timeout: Subprocess timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Stripped stdout from the command.
|
||||
"""
|
||||
if db_user and db_name:
|
||||
escaped_sql = command.replace("'", "'\"'\"'")
|
||||
remote_cmd = f'docker exec {container} psql -U {db_user} -d {db_name} -t -A -c "{escaped_sql}"'
|
||||
else:
|
||||
remote_cmd = f"docker exec {container} {command}"
|
||||
result = ssh_exec(host, remote_cmd, user=user, timeout=timeout)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def wait_for_ssh(host: str, port: int = 22, max_attempts: int = 30, interval: int = 10) -> None:
|
||||
"""Wait for SSH to be available on a host using a pure-Python socket check.
|
||||
|
||||
Uses socket instead of ``nc(1)`` so it works on CI runners without
|
||||
netcat. Uses exponential backoff: starts at 2 s, doubles each
|
||||
attempt up to 10 s max.
|
||||
|
||||
Args:
|
||||
host: VM IP address or hostname.
|
||||
port: SSH port (default 22).
|
||||
max_attempts: Maximum number of connection attempts.
|
||||
interval: Base interval for backoff calculation (seconds).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If SSH is not available after *max_attempts*.
|
||||
"""
|
||||
for i in range(max_attempts):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=5):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
if i < max_attempts - 1:
|
||||
wait = min(2 * (2**i), 10)
|
||||
time.sleep(wait)
|
||||
raise RuntimeError(f"SSH not available on {host}:{port} after {max_attempts} attempts")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Operation step tracking with translated reports.
|
||||
|
||||
Provides a context manager that tracks multi-step operations and prints
|
||||
a status report on exit. Steps are marked as pending, in_progress,
|
||||
completed, or failed. On exception, the last in-progress step is
|
||||
marked as failed.
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.step_tracker import track_steps
|
||||
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("Install dependencies")
|
||||
install_deps()
|
||||
tracker.done()
|
||||
|
||||
tracker.begin("Run tests")
|
||||
run_tests()
|
||||
tracker.done()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import click
|
||||
|
||||
_STATUS_ICONS = {
|
||||
"completed": "✓",
|
||||
"failed": "✗",
|
||||
"pending": "○",
|
||||
"in_progress": "◌",
|
||||
}
|
||||
|
||||
_STATUS_COLORS = {
|
||||
"completed": "green",
|
||||
"failed": "red",
|
||||
"in_progress": "yellow",
|
||||
"pending": "white",
|
||||
}
|
||||
|
||||
|
||||
class Step:
|
||||
"""A single tracked step in an operation."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.status = "pending"
|
||||
|
||||
|
||||
class StepTracker:
|
||||
"""Tracks steps of an operation and prints a report on exit."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.steps: list[Step] = []
|
||||
|
||||
def begin(self, name: str) -> None:
|
||||
"""Start a new step.
|
||||
|
||||
Args:
|
||||
name: Human-readable step name.
|
||||
"""
|
||||
step = Step(name)
|
||||
self.steps.append(step)
|
||||
step.status = "in_progress"
|
||||
|
||||
def done(self) -> None:
|
||||
"""Mark the most recent in-progress step as completed."""
|
||||
if self.steps and self.steps[-1].status == "in_progress":
|
||||
self.steps[-1].status = "completed"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def track_steps() -> Generator[StepTracker, None, None]:
|
||||
"""Context manager that tracks steps and prints a report on exit.
|
||||
|
||||
On exception the last in-progress step is marked as failed.
|
||||
The report is printed in the ``finally`` block so it always appears.
|
||||
|
||||
Yields:
|
||||
A :class:`StepTracker` instance to track steps with.
|
||||
"""
|
||||
tracker = StepTracker()
|
||||
try:
|
||||
yield tracker
|
||||
except Exception:
|
||||
for step in reversed(tracker.steps):
|
||||
if step.status == "in_progress":
|
||||
step.status = "failed"
|
||||
raise
|
||||
finally:
|
||||
_print_report(tracker.steps)
|
||||
|
||||
|
||||
def _print_report(steps: list[Step]) -> None:
|
||||
"""Print an operation report to stdout."""
|
||||
click.secho("=== Operation Report ===", fg="bright_cyan")
|
||||
for step in steps:
|
||||
icon = _STATUS_ICONS.get(step.status, "?")
|
||||
color = _STATUS_COLORS.get(step.status)
|
||||
click.secho(f" {icon} {step.name} ({step.status})", fg=color)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Ansible Vault helpers for encrypting and decrypting YAML files.
|
||||
|
||||
Wraps ``ansible-vault`` to provide a convenient API for loading and
|
||||
saving vault-encrypted YAML files. Falls back to plain YAML when no
|
||||
vault-password file is available, making it safe to use in both
|
||||
local (with vault) and CI (without vault) environments.
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.vault import load_vault_yaml, save_vault_yaml
|
||||
|
||||
data = load_vault_yaml(Path("secrets.yml"), vault_pass=Path("vault-password"))
|
||||
data["new_key"] = "value"
|
||||
save_vault_yaml(Path("secrets.yml"), data, vault_pass=Path("vault-password"))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def encrypt_file(path: Path, vault_pass: Path) -> None:
|
||||
"""Encrypt a file in-place using ansible-vault.
|
||||
|
||||
Args:
|
||||
path: File to encrypt.
|
||||
vault_pass: Path to the vault-password file.
|
||||
"""
|
||||
subprocess.run( # nosec B603, B607
|
||||
[
|
||||
"ansible-vault",
|
||||
"encrypt",
|
||||
str(path),
|
||||
"--vault-password-file",
|
||||
str(vault_pass),
|
||||
"--encrypt-vault-id",
|
||||
"default",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def decrypt_file(path: Path, vault_pass: Path) -> None:
|
||||
"""Decrypt a file in-place using ansible-vault.
|
||||
|
||||
Args:
|
||||
path: File to decrypt.
|
||||
vault_pass: Path to the vault-password file.
|
||||
"""
|
||||
subprocess.run( # nosec B603, B607
|
||||
[
|
||||
"ansible-vault",
|
||||
"decrypt",
|
||||
str(path),
|
||||
"--vault-password-file",
|
||||
str(vault_pass),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def load_vault_yaml(path: Path, vault_pass: Path | None = None) -> dict:
|
||||
"""Load a YAML file, decrypting with ansible-vault if vault-password exists.
|
||||
|
||||
If *vault_pass* is None or doesn't exist, the file is read as plain
|
||||
YAML. If decryption fails (file not vault-encrypted), it falls back
|
||||
to plain YAML.
|
||||
|
||||
Args:
|
||||
path: YAML file path.
|
||||
vault_pass: Path to the vault-password file (optional).
|
||||
|
||||
Returns:
|
||||
Parsed YAML content as a dict (empty dict if file is empty).
|
||||
"""
|
||||
if vault_pass is None or not vault_pass.exists():
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["ansible-vault", "view", str(path), "--vault-password-file", str(vault_pass)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return yaml.safe_load(result.stdout) or {}
|
||||
if "is not vault encrypted" in result.stderr:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
result.check_returncode() # pragma: no cover
|
||||
return {} # pragma: no cover
|
||||
|
||||
|
||||
def save_vault_yaml(path: Path, data: dict, vault_pass: Path | None = None) -> None:
|
||||
"""Write YAML data, encrypting with ansible-vault if vault-password exists.
|
||||
|
||||
Args:
|
||||
path: Destination YAML file path.
|
||||
data: Data to serialize.
|
||||
vault_pass: Path to the vault-password file (optional).
|
||||
"""
|
||||
plain = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(plain)
|
||||
if vault_pass is not None and vault_pass.exists():
|
||||
subprocess.run( # nosec B603, B607
|
||||
[
|
||||
"ansible-vault",
|
||||
"encrypt",
|
||||
str(path),
|
||||
"--vault-password-file",
|
||||
str(vault_pass),
|
||||
"--encrypt-vault-id",
|
||||
"default",
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def is_encrypted(path: Path) -> bool:
|
||||
"""Check if a file is ansible-vault encrypted.
|
||||
|
||||
Args:
|
||||
path: File to check.
|
||||
|
||||
Returns:
|
||||
True if the file starts with the ``$ANSIBLE_VAULT`` marker.
|
||||
"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
first_line = f.readline()
|
||||
return "$ANSIBLE_VAULT" in first_line
|
||||
@@ -909,3 +909,65 @@ class TestGiteaClientActions:
|
||||
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_repo_variable_returns_value(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"value": "v0.28.1"}))
|
||||
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
|
||||
assert result == "v0.28.1"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_repo_variable_returns_none_on_404(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
client._session.request = MagicMock(return_value=not_found)
|
||||
result = client.get_repo_variable("MISSING_VAR")
|
||||
assert result is None
|
||||
|
||||
@patch("time.sleep")
|
||||
def test_get_repo_variable_reraises_non_404(self, mock_sleep: MagicMock) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
server_error = MagicMock()
|
||||
server_error.raise_for_status.side_effect = _mock_http_error(500, "server error")
|
||||
client._session.request = MagicMock(return_value=server_error)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.get_repo_variable("SOME_VAR")
|
||||
assert exc_info.value.status == 500
|
||||
|
||||
def test_set_repo_variable_updates_existing(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.set_repo_variable("PRODUCTION_DEPLOY_TAG", "v0.28.2")
|
||||
client._session.request.assert_called_once_with(
|
||||
"PUT",
|
||||
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"value": "v0.28.2"},
|
||||
)
|
||||
|
||||
def test_set_repo_variable_creates_on_404(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
created = _mock_response({})
|
||||
client._session.request = MagicMock(side_effect=[not_found, created])
|
||||
client.set_repo_variable("NEW_VAR", "v0.29.0")
|
||||
assert client._session.request.call_count == 2
|
||||
second_call = client._session.request.call_args_list[1]
|
||||
assert second_call.args[0] == "POST"
|
||||
assert second_call.args[1] == "https://git.example.com/repos/owner/repo/actions/variables/NEW_VAR"
|
||||
assert second_call.kwargs["json"] == {"value": "v0.29.0"}
|
||||
|
||||
def test_set_repo_variable_reraises_non_404(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
forbidden = MagicMock()
|
||||
forbidden.raise_for_status.side_effect = _mock_http_error(403, "forbidden")
|
||||
client._session.request = MagicMock(return_value=forbidden)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.set_repo_variable("SOME_VAR", "val")
|
||||
assert exc_info.value.status == 403
|
||||
|
||||
@@ -292,3 +292,18 @@ class TestCli:
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "does not match Vikunja" in result.output
|
||||
|
||||
def test_fails_with_double_prefix_in_vikunja_title(self) -> None:
|
||||
"""Vikunja title with task ID prefix causes double-prefix in PR title."""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="DEVX-1: Fix foo"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "should NOT include" in result.output
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -249,3 +250,14 @@ class TestMain:
|
||||
args, kwargs = mock_count.call_args
|
||||
assert "myorg" in args
|
||||
assert "myrepo" in args
|
||||
|
||||
@patch("devx.ci.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
|
||||
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
|
||||
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
|
||||
"""When no token is available, runner discovery falls back to env/default."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--count"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "3"
|
||||
args, _ = mock_count.call_args
|
||||
assert args[1] is None # token passed as None when missing
|
||||
|
||||
@@ -55,6 +55,38 @@ class TestExtractCliCommands:
|
||||
assert "real_cmd" in commands
|
||||
assert "pass" not in commands
|
||||
|
||||
def test_command_with_explicit_name_param(self, tmp_path: Path) -> None:
|
||||
"""When a command uses name="explicit-name", that name is extracted."""
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text(
|
||||
'@click.group()\ndef cli():\n pass\n@cli.command(name="my-command")\ndef my_command():\n pass\n'
|
||||
)
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert "my-command" in commands
|
||||
assert "my_command" not in commands
|
||||
|
||||
def test_command_with_help_kwarg_uses_def_name(self, tmp_path: Path) -> None:
|
||||
"""When a command uses help= kwarg but no name=, falls back to def name."""
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text(
|
||||
"@click.group()\ndef cli():\n pass\n"
|
||||
'@cli.command(help="Do something useful")\ndef do_something():\n pass\n'
|
||||
)
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert "do_something" in commands
|
||||
assert "Do something useful" not in commands
|
||||
|
||||
def test_command_with_help_translation_uses_def_name(self, tmp_path: Path) -> None:
|
||||
"""When a command uses help=_() translation, falls back to def name."""
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text(
|
||||
"@click.group()\ndef cli():\n pass\n"
|
||||
'@cli.command(help=_("Install and configure things"))\ndef install():\n pass\n'
|
||||
)
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert "install" in commands
|
||||
assert "Install and configure things" not in commands
|
||||
|
||||
|
||||
class TestCheckCommandDocumented:
|
||||
def test_finds_command_in_heading(self) -> None:
|
||||
@@ -195,3 +227,171 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_ci_scripts_dir_empty_skips_ci_checks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When --ci-scripts-dir is empty string, CI script checks are skipped."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", ""])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
# Should not mention any CI scripts
|
||||
assert "MISSING" not in result.output or "CI script" not in result.output
|
||||
|
||||
def test_ci_scripts_dir_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When --ci-scripts-dir points to a directory, scripts are detected from there."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "myapp"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
ci_dir = tmp_path / "ci"
|
||||
ci_dir.mkdir()
|
||||
(ci_dir / "my_script.py").write_text("# my script")
|
||||
(ci_dir / "__init__.py").write_text("")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("my_script.py")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", str(ci_dir)]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "my_script.py" in result.output
|
||||
assert "OK: my_script.py" in result.output
|
||||
|
||||
def test_ci_scripts_dir_nonexistent_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When --ci-scripts-dir points to a non-existent path, CI checks are skipped."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "myapp"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", "/nonexistent"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
def test_config_from_pyproject_ci_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When [tool.devx.doc_coverage] ci_scripts_dir is set in pyproject.toml, it's used."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "myapp"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
# Write pyproject.toml with ci_scripts_dir = ""
|
||||
(tmp_path / "pyproject.toml").write_text('[tool.devx.doc_coverage]\nci_scripts_dir = ""\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
def test_config_from_pyproject_docs_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When [tool.devx.doc_coverage] docs_dir is set in pyproject.toml, it's used."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
custom_docs = tmp_path / "custom-docs"
|
||||
(custom_docs / "user").mkdir(parents=True)
|
||||
(custom_docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "myapp"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(custom_docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(custom_docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(custom_docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
# Write pyproject.toml with custom docs_dir
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
f'[tool.devx.doc_coverage]\ndocs_dir = "{custom_docs}"\nci_scripts_dir = ""\n'
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
def test_config_from_pyproject_source_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When [tool.devx.doc_coverage] source_dir is set in pyproject.toml, it's used."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
custom_src = tmp_path / "custom-src" / "myapp"
|
||||
custom_src.mkdir(parents=True)
|
||||
(custom_src / "__init__.py").write_text("")
|
||||
(custom_src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(custom_src / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
# Write pyproject.toml with custom source_dir
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
f'[tool.devx.doc_coverage]\nsource_dir = "{custom_src}"\nci_scripts_dir = ""\n'
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
def test_config_doc_coverage_not_dict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When [tool.devx.doc_coverage] is not a dict, falls back to defaults."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "myapp"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
# Write pyproject.toml with doc_coverage as a non-dict value
|
||||
(tmp_path / "pyproject.toml").write_text('[tool.devx]\ndoc_coverage = "not-a-dict"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
@@ -186,6 +186,28 @@ class TestCli:
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
@patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token"))
|
||||
def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None:
|
||||
"""When no token is available, cross-runner polling is skipped."""
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("devx.molecule.molecule_ci_guard.poll_for_other_failures") as mock_poll,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 0
|
||||
mock_poll.assert_not_called()
|
||||
|
||||
def test_invalid_pair_format_raises(self) -> None:
|
||||
"""Pair with fewer than 2 parts should raise."""
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -219,3 +220,14 @@ class TestMain:
|
||||
args, kwargs = mock_count.call_args
|
||||
assert "myorg" in args
|
||||
assert "myrepo" in args
|
||||
|
||||
@patch("devx.molecule.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
|
||||
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
|
||||
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
|
||||
"""When no token is available, runner discovery falls back to env/default."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--count"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "3"
|
||||
args, _ = mock_count.call_args
|
||||
assert args[1] is None # token passed as None when missing
|
||||
|
||||
@@ -12,9 +12,14 @@ from devx.tools.pr_label import cli
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--pr", "42", "--label", "ready-to-merge"],
|
||||
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
@@ -153,9 +153,14 @@ class TestPrintLogs:
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--pr", "42"],
|
||||
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
@@ -756,9 +756,10 @@ class TestMain:
|
||||
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
|
||||
def test_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": ""})
|
||||
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
@@ -122,9 +122,14 @@ class TestWaitForCompletion:
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--pr", "42"],
|
||||
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Unit tests for devx.ci.record_deployed_tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.record_deployed_tag import main
|
||||
|
||||
|
||||
class TestRecordDeployedTag:
|
||||
@patch("devx.ci.record_deployed_tag.GiteaClient")
|
||||
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||
def test_records_production_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
client_instance = MagicMock()
|
||||
mock_client.return_value = client_instance
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "PRODUCTION_DEPLOY_TAG" in result.output
|
||||
assert "v1.0.0" in result.output
|
||||
client_instance.set_repo_variable.assert_called_once_with("PRODUCTION_DEPLOY_TAG", "v1.0.0")
|
||||
|
||||
@patch("devx.ci.record_deployed_tag.GiteaClient")
|
||||
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||
def test_records_staging_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
client_instance = MagicMock()
|
||||
mock_client.return_value = client_instance
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "staging", "--tag", "master-abc123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "STAGING_DEPLOY_TAG" in result.output
|
||||
client_instance.set_repo_variable.assert_called_once_with("STAGING_DEPLOY_TAG", "master-abc123")
|
||||
|
||||
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||
def test_token_error_exits_nonzero(self, mock_token: MagicMock) -> None:
|
||||
import click
|
||||
|
||||
mock_token.side_effect = click.ClickException("No token available")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "No token available" in result.output
|
||||
|
||||
def test_invalid_env_choice(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "invalid", "--tag", "v1.0.0"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_missing_tag_option(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "production"])
|
||||
assert result.exit_code != 0
|
||||
@@ -19,6 +19,7 @@ from devx.ci.sync_wiki import (
|
||||
main,
|
||||
sync_files,
|
||||
transform_links,
|
||||
wiki_filename,
|
||||
)
|
||||
|
||||
|
||||
@@ -96,6 +97,29 @@ class TestGetWikiCloneUrl:
|
||||
assert "owner/repo.wiki.git" in url
|
||||
|
||||
|
||||
class TestWikiFilename:
|
||||
def test_no_dashes(self) -> None:
|
||||
assert wiki_filename("Home") == "Home.md"
|
||||
|
||||
def test_single_word(self) -> None:
|
||||
assert wiki_filename("Architecture") == "Architecture.md"
|
||||
|
||||
def test_with_dashes_adds_marker(self) -> None:
|
||||
assert wiki_filename("Getting-Started") == "Getting-Started.-.md"
|
||||
|
||||
def test_spaces_become_dashes_with_marker(self) -> None:
|
||||
# "Getting Started" → "Getting-Started" (has dash) → marker added
|
||||
assert wiki_filename("Getting Started") == "Getting-Started.-.md"
|
||||
|
||||
def test_spaces_no_dashes_no_marker(self) -> None:
|
||||
# "Foo Bar" → "Foo-Bar" (has dash) → marker added
|
||||
assert wiki_filename("Foo Bar") == "Foo-Bar.-.md"
|
||||
|
||||
def test_single_word_with_spaces_no_dash(self) -> None:
|
||||
# No dash at all after conversion → no marker
|
||||
assert wiki_filename("HelloWorld") == "HelloWorld.md"
|
||||
|
||||
|
||||
class TestCloneWiki:
|
||||
@patch("devx.ci.sync_wiki.subprocess.run")
|
||||
def test_clone_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
@@ -232,9 +256,10 @@ class TestCommitAndPush:
|
||||
|
||||
class TestMain:
|
||||
def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
result = runner.invoke(main, [], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@@ -323,6 +348,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_init.assert_called_once()
|
||||
|
||||
@patch("devx.ci.sync_wiki.time.sleep")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki")
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
|
||||
@@ -331,6 +357,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -380,6 +407,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "No push needed" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.time.sleep")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki", side_effect=[True, False])
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
|
||||
@@ -388,6 +416,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -404,6 +433,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "could not clone" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.time.sleep")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki")
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
|
||||
@@ -412,6 +442,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Unit tests for devx.utils.confirm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devx.utils.confirm import validate_confirmation
|
||||
|
||||
|
||||
class TestValidateConfirmation:
|
||||
def test_exact_match(self) -> None:
|
||||
assert validate_confirmation("deploy-production", "deploy-production") is True
|
||||
|
||||
def test_mismatch(self) -> None:
|
||||
assert validate_confirmation("deploy-staging", "deploy-production") is False
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert validate_confirmation("", "deploy-production") is False
|
||||
|
||||
def test_case_sensitive(self) -> None:
|
||||
assert validate_confirmation("Deploy-Production", "deploy-production") is False
|
||||
|
||||
def test_partial_match(self) -> None:
|
||||
assert validate_confirmation("deploy", "deploy-production") is False
|
||||
|
||||
def test_extra_whitespace(self) -> None:
|
||||
assert validate_confirmation("deploy-production ", "deploy-production") is False
|
||||
|
||||
def test_custom_expected(self) -> None:
|
||||
assert validate_confirmation("yes-delete-all", "yes-delete-all") is True
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Unit tests for devx.utils.crypto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devx.utils.crypto import (
|
||||
_DIGITS,
|
||||
_LOWER,
|
||||
_SYMBOLS,
|
||||
_UPPER,
|
||||
generate_hex_secret,
|
||||
generate_password,
|
||||
generate_secret,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateSecret:
|
||||
def test_returns_url_safe_string(self) -> None:
|
||||
secret = generate_secret()
|
||||
assert isinstance(secret, str)
|
||||
assert len(secret) > 0
|
||||
# URL-safe base64 characters only
|
||||
assert re.match(r"^[A-Za-z0-9_-]+$", secret)
|
||||
|
||||
def test_never_starts_with_dash(self) -> None:
|
||||
for _ in range(1000):
|
||||
secret = generate_secret()
|
||||
assert not secret.startswith("-")
|
||||
|
||||
|
||||
class TestGeneratePassword:
|
||||
def test_default_length(self) -> None:
|
||||
pw = generate_password()
|
||||
assert len(pw) == 32
|
||||
|
||||
def test_custom_length(self) -> None:
|
||||
pw = generate_password(length=64)
|
||||
assert len(pw) == 64
|
||||
|
||||
def test_contains_all_char_classes(self) -> None:
|
||||
pw = generate_password(length=32)
|
||||
assert any(c in _UPPER for c in pw), "Missing uppercase"
|
||||
assert any(c in _LOWER for c in pw), "Missing lowercase"
|
||||
assert any(c in _DIGITS for c in pw), "Missing digits"
|
||||
assert any(c in _SYMBOLS for c in pw), "Missing symbols"
|
||||
|
||||
def test_first_char_alphanumeric(self) -> None:
|
||||
for _ in range(1000):
|
||||
pw = generate_password()
|
||||
assert pw[0] not in _SYMBOLS, f"First char '{pw[0]}' is a symbol"
|
||||
|
||||
def test_minimum_length_4(self) -> None:
|
||||
pw = generate_password(length=4)
|
||||
assert len(pw) == 4
|
||||
|
||||
|
||||
class TestGenerateHexSecret:
|
||||
def test_returns_hex_string(self) -> None:
|
||||
secret = generate_hex_secret(length=32)
|
||||
assert re.match(r"^[0-9a-f]+$", secret)
|
||||
|
||||
def test_correct_length(self) -> None:
|
||||
secret = generate_hex_secret(length=20)
|
||||
assert len(secret) == 20
|
||||
|
||||
def test_empty_for_zero(self) -> None:
|
||||
secret = generate_hex_secret(length=0)
|
||||
assert secret == ""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for devx.utils.json_registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.utils.json_registry import JsonRegistry
|
||||
|
||||
|
||||
class TestJsonRegistry:
|
||||
def test_add_and_get(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item1", host="10.0.0.1", user="deploy")
|
||||
info = reg.get("item1")
|
||||
assert info is not None
|
||||
assert info["host"] == "10.0.0.1"
|
||||
assert info["user"] == "deploy"
|
||||
assert "created_at" in info
|
||||
|
||||
def test_get_nonexistent(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
assert reg.get("nope") is None
|
||||
|
||||
def test_remove(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item1", host="10.0.0.1")
|
||||
reg.remove("item1")
|
||||
assert reg.get("item1") is None
|
||||
|
||||
def test_remove_nonexistent_is_noop(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.remove("nonexistent") # should not raise
|
||||
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("a", host="1.1.1.1")
|
||||
reg.add("b", host="2.2.2.2")
|
||||
items = reg.list()
|
||||
assert set(items.keys()) == {"a", "b"}
|
||||
assert items["a"]["host"] == "1.1.1.1"
|
||||
|
||||
def test_list_empty(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
assert reg.list() == {}
|
||||
|
||||
def test_update_existing(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1", status="active")
|
||||
reg.update("item", status="inactive")
|
||||
info = reg.get("item")
|
||||
assert info["status"] == "inactive"
|
||||
assert info["host"] == "1.1.1.1" # unchanged
|
||||
|
||||
def test_update_nonexistent_raises(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
with pytest.raises(KeyError):
|
||||
reg.update("nonexistent", host="1.1.1.1")
|
||||
|
||||
def test_update_skips_none_values(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1")
|
||||
reg.update("item", host=None, status="active")
|
||||
info = reg.get("item")
|
||||
assert info["host"] == "1.1.1.1" # not overwritten by None
|
||||
assert info["status"] == "active"
|
||||
|
||||
def test_persistence_across_instances(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
reg1 = JsonRegistry(path)
|
||||
reg1.add("item", host="10.0.0.1")
|
||||
reg2 = JsonRegistry(path)
|
||||
info = reg2.get("item")
|
||||
assert info is not None
|
||||
assert info["host"] == "10.0.0.1"
|
||||
|
||||
def test_overwrite_existing(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1")
|
||||
reg.add("item", host="2.2.2.2")
|
||||
info = reg.get("item")
|
||||
assert info["host"] == "2.2.2.2"
|
||||
|
||||
def test_corrupt_json_returns_empty(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
path.write_text("{invalid json")
|
||||
reg = JsonRegistry(path)
|
||||
assert reg.list() == {}
|
||||
|
||||
def test_nonexistent_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "nonexistent.json")
|
||||
assert reg.list() == {}
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "subdir" / "deeper" / "state.json"
|
||||
reg = JsonRegistry(path)
|
||||
reg.add("item", host="1.1.1.1")
|
||||
assert path.exists()
|
||||
|
||||
def test_get_returns_copy(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1", tags=["a", "b"])
|
||||
info = reg.get("item")
|
||||
assert info is not None
|
||||
info["tags"].append("c")
|
||||
# Original should be unchanged
|
||||
info2 = reg.get("item")
|
||||
assert info2 is not None
|
||||
assert info2["tags"] == ["a", "b"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Unit tests for devx.utils.logging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from devx.utils.logging import get_logger
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
def test_returns_logger_with_handlers(self) -> None:
|
||||
logger = get_logger("test_devx_unit_1")
|
||||
assert logger.handlers
|
||||
assert isinstance(logger.handlers[0], logging.FileHandler)
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
logger1 = get_logger("test_devx_unit_2")
|
||||
initial_count = len(logger1.handlers)
|
||||
logger2 = get_logger("test_devx_unit_2")
|
||||
assert logger1 is logger2
|
||||
assert len(logger2.handlers) == initial_count
|
||||
|
||||
def test_log_level_is_debug(self) -> None:
|
||||
logger = get_logger("test_devx_unit_3")
|
||||
assert logger.level == logging.DEBUG
|
||||
|
||||
def test_file_handler_level_is_debug(self) -> None:
|
||||
logger = get_logger("test_devx_unit_4")
|
||||
file_handler = logger.handlers[0]
|
||||
assert file_handler.level == logging.DEBUG
|
||||
|
||||
def test_default_name(self) -> None:
|
||||
logger = get_logger()
|
||||
assert logger.name == "devx"
|
||||
|
||||
def test_creates_log_directory(self, tmp_path: Path) -> None:
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
get_logger("test_app_creates_dir")
|
||||
log_dir = tmp_path / ".local" / "state" / "test_app_creates_dir" / "logs"
|
||||
assert log_dir.exists()
|
||||
assert (log_dir / "test_app_creates_dir.log").exists()
|
||||
|
||||
def test_formatter_includes_timestamp(self) -> None:
|
||||
logger = get_logger("test_devx_unit_5")
|
||||
file_handler = logger.handlers[0]
|
||||
fmt = file_handler.formatter
|
||||
assert fmt is not None
|
||||
assert "%(asctime)s" in fmt._fmt
|
||||
assert "%(levelname)s" in fmt._fmt
|
||||
assert "%(name)s" in fmt._fmt
|
||||
assert "%(message)s" in fmt._fmt
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for devx.utils.network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.utils.network import check_http_connectivity, wait_for_ssh
|
||||
|
||||
_no_sleep = MagicMock()
|
||||
|
||||
|
||||
class TestCheckHttpConnectivity:
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_success(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
check_http_connectivity("https://example.com", max_attempts=3)
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_retries_on_connection_error(self, mock_get: MagicMock) -> None:
|
||||
mock_get.side_effect = [
|
||||
requests.exceptions.ConnectionError("refused"),
|
||||
requests.exceptions.ConnectionError("refused"),
|
||||
MagicMock(status_code=200),
|
||||
]
|
||||
check_http_connectivity("https://example.com", max_attempts=5, sleep=_no_sleep)
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_raises_after_max_attempts(self, mock_get: MagicMock) -> None:
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError("refused")
|
||||
with pytest.raises(requests.exceptions.ConnectionError):
|
||||
check_http_connectivity("https://example.com", max_attempts=2, sleep=_no_sleep)
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_verify_false(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
check_http_connectivity("https://example.com", verify=False)
|
||||
mock_get.assert_called_once_with("https://example.com", timeout=10, verify=False)
|
||||
|
||||
|
||||
class TestWaitForSsh:
|
||||
@patch("devx.utils.network.socket.create_connection")
|
||||
def test_immediate_success(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1")
|
||||
mock_conn.assert_called_once()
|
||||
|
||||
@patch("devx.utils.network.socket.create_connection")
|
||||
def test_retries_until_success(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.side_effect = [
|
||||
OSError("refused"),
|
||||
OSError("refused"),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1", max_attempts=5, sleep=_no_sleep)
|
||||
assert mock_conn.call_count == 3
|
||||
|
||||
@patch("devx.utils.network.socket.create_connection")
|
||||
def test_timeout_after_max_attempts(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.side_effect = OSError("refused")
|
||||
with pytest.raises(RuntimeError, match="SSH not available"):
|
||||
wait_for_ssh("10.0.0.1", max_attempts=3, sleep=_no_sleep)
|
||||
assert mock_conn.call_count == 3
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Unit tests for devx.utils.ssh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.utils.ssh import docker_exec_on_vm, ssh_exec, wait_for_ssh
|
||||
|
||||
|
||||
class TestSshExec:
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
result = ssh_exec("10.0.0.1", "uname -a")
|
||||
assert result.returncode == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_failure_with_check(self, mock_run: MagicMock) -> None:
|
||||
mock_result = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
mock_result.check_returncode.side_effect = subprocess.CalledProcessError(1, "ssh")
|
||||
mock_run.return_value = mock_result
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
ssh_exec("10.0.0.1", "false")
|
||||
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_failure_without_check(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
result = ssh_exec("10.0.0.1", "false", check=False)
|
||||
assert result.returncode == 1
|
||||
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_custom_user(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
ssh_exec("10.0.0.1", "whoami", user="root")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "root@10.0.0.1" in cmd
|
||||
|
||||
|
||||
class TestDockerExecOnVm:
|
||||
@patch("devx.utils.ssh.ssh_exec")
|
||||
def test_simple_command(self, mock_ssh: MagicMock) -> None:
|
||||
mock_ssh.return_value = MagicMock(stdout="output\n")
|
||||
result = docker_exec_on_vm("10.0.0.1", "mycontainer", "ls /")
|
||||
assert result == "output"
|
||||
mock_ssh.assert_called_once_with("10.0.0.1", "docker exec mycontainer ls /", user="deploy", timeout=30)
|
||||
|
||||
@patch("devx.utils.ssh.ssh_exec")
|
||||
def test_psql_mode(self, mock_ssh: MagicMock) -> None:
|
||||
mock_ssh.return_value = MagicMock(stdout="result\n")
|
||||
result = docker_exec_on_vm("10.0.0.1", "db", "SELECT 1", db_user="postgres", db_name="mydb")
|
||||
assert result == "result"
|
||||
call_args = mock_ssh.call_args[0][1]
|
||||
assert "psql -U postgres -d mydb" in call_args
|
||||
assert "SELECT 1" in call_args
|
||||
|
||||
@patch("devx.utils.ssh.ssh_exec")
|
||||
def test_psql_escapes_single_quotes(self, mock_ssh: MagicMock) -> None:
|
||||
mock_ssh.return_value = MagicMock(stdout="\n")
|
||||
docker_exec_on_vm("10.0.0.1", "db", "SELECT 'it''s ok'", db_user="pg", db_name="db")
|
||||
call_args = mock_ssh.call_args[0][1]
|
||||
assert "'\"'\"'" in call_args
|
||||
|
||||
|
||||
class TestWaitForSsh:
|
||||
@patch("devx.utils.ssh.socket.create_connection")
|
||||
def test_immediate_success(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1")
|
||||
mock_conn.assert_called_once()
|
||||
|
||||
@patch("devx.utils.ssh.socket.create_connection")
|
||||
@patch("devx.utils.ssh.time.sleep")
|
||||
def test_retries_until_success(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
|
||||
# Fail twice, then succeed
|
||||
mock_conn.side_effect = [
|
||||
OSError("refused"),
|
||||
OSError("refused"),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1", max_attempts=5)
|
||||
assert mock_conn.call_count == 3
|
||||
|
||||
@patch("devx.utils.ssh.socket.create_connection")
|
||||
@patch("devx.utils.ssh.time.sleep")
|
||||
def test_timeout_after_max_attempts(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
|
||||
mock_conn.side_effect = OSError("refused")
|
||||
with pytest.raises(RuntimeError, match="SSH not available"):
|
||||
wait_for_ssh("10.0.0.1", max_attempts=3)
|
||||
assert mock_conn.call_count == 3
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Unit tests for devx.utils.step_tracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.utils.step_tracker import Step, StepTracker, track_steps
|
||||
|
||||
|
||||
class TestStep:
|
||||
def test_initial_status_is_pending(self) -> None:
|
||||
step = Step("install")
|
||||
assert step.status == "pending"
|
||||
assert step.name == "install"
|
||||
|
||||
|
||||
class TestStepTracker:
|
||||
def test_begin_adds_step_as_in_progress(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("install deps")
|
||||
assert len(tracker.steps) == 1
|
||||
assert tracker.steps[0].status == "in_progress"
|
||||
|
||||
def test_done_marks_last_in_progress_as_completed(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
assert tracker.steps[0].status == "completed"
|
||||
|
||||
def test_done_no_op_if_no_in_progress(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.done() # should not raise, no-op
|
||||
assert tracker.steps[0].status == "completed"
|
||||
|
||||
def test_done_no_op_if_empty(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.done() # should not raise
|
||||
|
||||
def test_multiple_steps(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
tracker.done()
|
||||
assert len(tracker.steps) == 2
|
||||
assert tracker.steps[0].status == "completed"
|
||||
assert tracker.steps[1].status == "completed"
|
||||
|
||||
|
||||
class TestTrackSteps:
|
||||
def test_successful_operation(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
tracker.done()
|
||||
assert len(tracker.steps) == 2
|
||||
assert all(s.status == "completed" for s in tracker.steps)
|
||||
|
||||
def test_exception_marks_in_progress_as_failed(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
raise ValueError("boom")
|
||||
assert tracker.steps[0].status == "completed"
|
||||
assert tracker.steps[1].status == "failed"
|
||||
|
||||
def test_pending_step_stays_pending_on_exception(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with pytest.raises(ValueError):
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
tracker.done()
|
||||
tracker.begin("step3") # in_progress
|
||||
# step4 is pending (not started)
|
||||
raise ValueError("oops")
|
||||
assert tracker.steps[2].status == "failed"
|
||||
|
||||
def test_empty_operation(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with track_steps() as tracker:
|
||||
pass
|
||||
assert tracker.steps == []
|
||||
|
||||
def test_report_printed_on_success(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(_cmd_success, [], color=False)
|
||||
assert result.exit_code == 0
|
||||
assert "Operation Report" in result.output
|
||||
assert "step1" in result.output
|
||||
|
||||
def test_report_printed_on_failure(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(_cmd_failure, [], color=False)
|
||||
assert result.exit_code != 0
|
||||
assert "Operation Report" in result.output
|
||||
|
||||
|
||||
@click.command()
|
||||
def _cmd_success() -> None:
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
|
||||
|
||||
@click.command()
|
||||
def _cmd_failure() -> None:
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
raise ValueError("oops")
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unit tests for devx.utils.vault."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from devx.utils.vault import (
|
||||
decrypt_file,
|
||||
encrypt_file,
|
||||
is_encrypted,
|
||||
load_vault_yaml,
|
||||
save_vault_yaml,
|
||||
)
|
||||
|
||||
|
||||
class TestIsEncrypted:
|
||||
def test_encrypted_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "secret.yml"
|
||||
f.write_text("$ANSIBLE_VAULT;1.1;AES256\n9382928...\n")
|
||||
assert is_encrypted(f) is True
|
||||
|
||||
def test_plain_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "plain.yml"
|
||||
f.write_text("key: value\n")
|
||||
assert is_encrypted(f) is False
|
||||
|
||||
|
||||
class TestLoadVaultYaml:
|
||||
def test_plain_yaml_no_vault_pass(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "data.yml"
|
||||
f.write_text("key: value\nlist:\n - a\n - b\n")
|
||||
data = load_vault_yaml(f)
|
||||
assert data == {"key": "value", "list": ["a", "b"]}
|
||||
|
||||
def test_empty_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.yml"
|
||||
f.write_text("")
|
||||
data = load_vault_yaml(f)
|
||||
assert data == {}
|
||||
|
||||
def test_vault_pass_not_exists(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "data.yml"
|
||||
f.write_text("key: value\n")
|
||||
data = load_vault_yaml(f, vault_pass=tmp_path / "nonexistent")
|
||||
assert data == {"key": "value"}
|
||||
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_encrypted_file_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "secret.yml"
|
||||
f.write_text("$ANSIBLE_VAULT\n...")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="key: decrypted\n", stderr="")
|
||||
data = load_vault_yaml(f, vault_pass=vp)
|
||||
assert data == {"key": "decrypted"}
|
||||
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_not_vault_encrypted_fallback(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "plain.yml"
|
||||
f.write_text("key: value\n")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="is not vault encrypted")
|
||||
data = load_vault_yaml(f, vault_pass=vp)
|
||||
assert data == {"key": "value"}
|
||||
|
||||
|
||||
class TestSaveVaultYaml:
|
||||
def test_save_plain(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "output.yml"
|
||||
save_vault_yaml(f, {"key": "value"})
|
||||
content = f.read_text()
|
||||
assert "key: value" in content
|
||||
|
||||
def test_save_with_vault_pass_not_exists(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "output.yml"
|
||||
vp = tmp_path / "nonexistent"
|
||||
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
|
||||
# Should save as plain YAML
|
||||
content = f.read_text()
|
||||
assert "key: value" in content
|
||||
assert "$ANSIBLE_VAULT" not in content
|
||||
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_save_and_encrypt(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "output.yml"
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
|
||||
# File should be written
|
||||
assert f.exists()
|
||||
# ansible-vault encrypt should be called
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ansible-vault" in cmd
|
||||
assert "encrypt" in cmd
|
||||
|
||||
|
||||
class TestEncryptFile:
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "file.yml"
|
||||
f.write_text("key: value")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
encrypt_file(f, vp)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ansible-vault" in cmd
|
||||
assert "encrypt" in cmd
|
||||
assert str(f) in cmd
|
||||
assert str(vp) in cmd
|
||||
|
||||
|
||||
class TestDecryptFile:
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "file.yml"
|
||||
f.write_text("$ANSIBLE_VAULT\n...")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
decrypt_file(f, vp)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ansible-vault" in cmd
|
||||
assert "decrypt" in cmd
|
||||
assert str(f) in cmd
|
||||
assert str(vp) in cmd
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for devx.ci.validate_deploy_ref."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.validate_deploy_ref import main
|
||||
|
||||
|
||||
class TestValidateDeployRef:
|
||||
def test_valid_tag_prints_ref(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef1234567890\n", stderr="")
|
||||
result = runner.invoke(main, ["--tag", "v1.0.0"])
|
||||
assert result.exit_code == 0
|
||||
assert "v1.0.0" in result.output
|
||||
|
||||
def test_invalid_tag_exits_nonzero(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
result = runner.invoke(main, ["--tag", "nonexistent"])
|
||||
assert result.exit_code == 1
|
||||
assert "does not exist" in result.output
|
||||
|
||||
def test_no_tag_without_allow_empty_exits_nonzero(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 1
|
||||
assert "No tag specified" in result.output
|
||||
|
||||
def test_allow_empty_prints_pr_mode(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--allow-empty"])
|
||||
assert result.exit_code == 0
|
||||
assert "PR mode" in result.output
|
||||
|
||||
def test_github_output_writes_ref(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
gh_output = tmp_path / "github_output"
|
||||
gh_output.write_text("")
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
|
||||
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
|
||||
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_output.read_text()
|
||||
assert "deploy-ref=v1.0.0" in content
|
||||
|
||||
def test_github_output_without_env_var_exits_nonzero(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
|
||||
with runner.isolation(env={"GITHUB_OUTPUT": ""}):
|
||||
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
|
||||
assert result.exit_code == 1
|
||||
assert "GITHUB_OUTPUT" in result.output
|
||||
|
||||
def test_allow_empty_with_github_output(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
gh_output = tmp_path / "github_output"
|
||||
gh_output.write_text("")
|
||||
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
|
||||
result = runner.invoke(main, ["--allow-empty", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
assert "deploy-ref=" in gh_output.read_text()
|
||||
Reference in New Issue
Block a user